From 5d9de8ed1f49d41148b349ffaf20a0ed0963fe3c Mon Sep 17 00:00:00 2001
From: Haitao Pan
Date: Tue, 17 Mar 2026 13:24:41 +0800
Subject: [PATCH 01/10] Add tenant-aware XWorkmate console flows
---
src/app/(auth)/login/LoginContent.tsx | 4 +-
src/app/AppProviders.tsx | 4 +-
src/app/api/auth/login/route.ts | 190 ++++---
src/app/api/auth/mfa/disable/route.ts | 58 +-
src/app/api/auth/mfa/verify/route.ts | 137 +++--
.../api/auth/oauth/login/[provider]/route.ts | 24 +
src/app/api/auth/session/route.ts | 360 ++++++------
src/app/api/auth/token/exchange/route.ts | 115 ++--
src/app/api/sandbox/assume/revert/route.ts | 89 +--
src/app/api/sandbox/assume/route.ts | 110 ++--
src/app/api/xworkmate/profile/route.ts | 81 +++
src/app/xworkmate/admin/page.tsx | 56 ++
src/app/xworkmate/integrations/page.tsx | 53 ++
src/app/xworkmate/page.tsx | 36 +-
.../xworkmate/XWorkmateProfileEditor.tsx | 524 ++++++++++++++++++
.../xworkmate/XWorkmateWorkspacePage.tsx | 213 +++++--
src/lib/authGateway.ts | 178 +++---
src/lib/xworkmate/host.ts | 39 ++
src/lib/xworkmate/types.ts | 76 +++
src/server/account/session.ts | 20 +
src/server/xworkmate/profile.ts | 73 +++
src/state/openclawConsoleStore.ts | 344 ++++++++----
22 files changed, 2127 insertions(+), 657 deletions(-)
create mode 100644 src/app/api/auth/oauth/login/[provider]/route.ts
create mode 100644 src/app/api/xworkmate/profile/route.ts
create mode 100644 src/app/xworkmate/admin/page.tsx
create mode 100644 src/app/xworkmate/integrations/page.tsx
create mode 100644 src/components/xworkmate/XWorkmateProfileEditor.tsx
create mode 100644 src/lib/xworkmate/host.ts
create mode 100644 src/lib/xworkmate/types.ts
create mode 100644 src/server/xworkmate/profile.ts
diff --git a/src/app/(auth)/login/LoginContent.tsx b/src/app/(auth)/login/LoginContent.tsx
index 9aa0139..8133c5c 100644
--- a/src/app/(auth)/login/LoginContent.tsx
+++ b/src/app/(auth)/login/LoginContent.tsx
@@ -74,8 +74,8 @@ export default function LoginContent({
`${accountServiceBaseUrl}/api/auth/login`;
const socialButtonsDisabled = false;
- const githubAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/github`;
- const googleAuthUrl = `${accountServiceBaseUrl}/api/auth/oauth/login/google`;
+ const githubAuthUrl = "/api/auth/oauth/login/github";
+ const googleAuthUrl = "/api/auth/oauth/login/google";
useEffect(() => {
const exchangeCode = searchParams.get("exchange_code");
diff --git a/src/app/AppProviders.tsx b/src/app/AppProviders.tsx
index a5c1d70..eb5275c 100644
--- a/src/app/AppProviders.tsx
+++ b/src/app/AppProviders.tsx
@@ -19,6 +19,7 @@ export function AppProviders({
}) {
const { isOpen, isMinimized, close, toggleOpen } = useMoltbotStore();
const applyDefaults = useOpenClawConsoleStore((state) => state.applyDefaults);
+ const setScope = useOpenClawConsoleStore((state) => state.setScope);
const pathname = usePathname();
const [isMobileViewport, setIsMobileViewport] = useState(false);
const isOpenClawWorkspace =
@@ -29,8 +30,9 @@ export function AppProviders({
!isOpenClawWorkspace && isOpen && !isMinimized && !isMobileViewport;
useEffect(() => {
+ setScope("global", assistantDefaults);
applyDefaults(assistantDefaults);
- }, [applyDefaults, assistantDefaults]);
+ }, [applyDefaults, assistantDefaults, setScope]);
useEffect(() => {
if (typeof window === "undefined") {
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 08021ba..042553a 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -1,123 +1,175 @@
-import { cookies } from 'next/headers'
-import { NextRequest, NextResponse } from 'next/server'
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
-import { applyMfaCookie, applySessionCookie, clearMfaCookie, clearSessionCookie, deriveMaxAgeFromExpires, MFA_COOKIE_NAME } from '@lib/authGateway'
-import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
+import {
+ applyMfaCookie,
+ applySessionCookie,
+ clearMfaCookie,
+ clearSessionCookie,
+ deriveMaxAgeFromExpires,
+ MFA_COOKIE_NAME,
+} from "@lib/authGateway";
+import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
type LoginPayload = {
- email?: string
- password?: string
- remember?: boolean
- totp?: string
- code?: string
- token?: string
-}
+ email?: string;
+ password?: string;
+ remember?: boolean;
+ totp?: string;
+ code?: string;
+ token?: string;
+};
type AccountLoginResponse = {
- token?: string
- expiresAt?: string
- error?: string
- mfaToken?: string
- needMfa?: boolean
- mfaEnabled?: boolean
-}
+ token?: string;
+ expiresAt?: string;
+ error?: string;
+ mfaToken?: string;
+ needMfa?: boolean;
+ mfaEnabled?: boolean;
+};
function normalizeEmail(value: unknown) {
- return typeof value === 'string' ? value.trim().toLowerCase() : ''
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function normalizeCode(value: unknown) {
- return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
+ return typeof value === "string" ? value.replace(/\D/g, "").slice(0, 6) : "";
}
export async function POST(request: NextRequest) {
- let payload: LoginPayload
+ let payload: LoginPayload;
try {
- payload = (await request.json()) as LoginPayload
+ payload = (await request.json()) as LoginPayload;
} catch (error) {
- console.error('Failed to decode login payload', error)
- return NextResponse.json({ success: false, error: 'invalid_request', needMfa: false }, { status: 400 })
+ console.error("Failed to decode login payload", error);
+ return NextResponse.json(
+ { success: false, error: "invalid_request", needMfa: false },
+ { status: 400 },
+ );
}
- const email = normalizeEmail(payload?.email)
- const password = typeof payload?.password === 'string' ? payload.password : ''
- const totpCode = normalizeCode(payload?.totp ?? payload?.code)
- const remember = Boolean(payload?.remember)
+ const email = normalizeEmail(payload?.email);
+ const password =
+ typeof payload?.password === "string" ? payload.password : "";
+ const totpCode = normalizeCode(payload?.totp ?? payload?.code);
+ const remember = Boolean(payload?.remember);
if (!email || !password) {
- return NextResponse.json({ success: false, error: 'missing_credentials', needMfa: false }, { status: 400 })
+ return NextResponse.json(
+ { success: false, error: "missing_credentials", needMfa: false },
+ { status: 400 },
+ );
}
try {
- const loginBody: Record = { email, password }
+ const loginBody: Record = { email, password };
if (totpCode) {
- loginBody.totpCode = totpCode
+ loginBody.totpCode = totpCode;
}
const response = await fetch(`${ACCOUNT_API_BASE}/login`, {
- method: 'POST',
+ method: "POST",
headers: {
- 'Content-Type': 'application/json',
+ "Content-Type": "application/json",
},
body: JSON.stringify(loginBody),
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
- const data = (await response.json().catch(() => ({}))) as AccountLoginResponse
+ const data = (await response
+ .json()
+ .catch(() => ({}))) as AccountLoginResponse;
- if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
- const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt)
- const effectiveMaxAge = remember ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30) : maxAgeFromBackend
- const result = NextResponse.json({ success: true, error: null, needMfa: false })
- applySessionCookie(result, data.token, effectiveMaxAge)
- clearMfaCookie(result)
- return result
+ if (
+ response.ok &&
+ typeof data?.token === "string" &&
+ data.token.length > 0
+ ) {
+ const maxAgeFromBackend = deriveMaxAgeFromExpires(data?.expiresAt);
+ const effectiveMaxAge = remember
+ ? Math.max(maxAgeFromBackend, 60 * 60 * 24 * 30)
+ : maxAgeFromBackend;
+ const result = NextResponse.json({
+ success: true,
+ error: null,
+ needMfa: false,
+ });
+ applySessionCookie(
+ result,
+ data.token,
+ effectiveMaxAge,
+ request.headers.get("host") ?? undefined,
+ );
+ clearMfaCookie(result);
+ return result;
}
- const errorCode = typeof data?.error === 'string' ? data.error : 'authentication_failed'
- const needsMfa = Boolean(data?.needMfa || errorCode === 'mfa_required' || errorCode === 'mfa_setup_required')
+ const errorCode =
+ typeof data?.error === "string" ? data.error : "authentication_failed";
+ const needsMfa = Boolean(
+ data?.needMfa ||
+ errorCode === "mfa_required" ||
+ errorCode === "mfa_setup_required",
+ );
- if ((response.status === 401 || response.status === 403 || needsMfa) && typeof data?.mfaToken === 'string') {
- const result = NextResponse.json({ success: false, error: errorCode, needMfa: true }, { status: 401 })
- applyMfaCookie(result, data.mfaToken)
- clearSessionCookie(result)
- return result
+ if (
+ (response.status === 401 || response.status === 403 || needsMfa) &&
+ typeof data?.mfaToken === "string"
+ ) {
+ const result = NextResponse.json(
+ { success: false, error: errorCode, needMfa: true },
+ { status: 401 },
+ );
+ applyMfaCookie(result, data.mfaToken);
+ clearSessionCookie(result, request.headers.get("host") ?? undefined);
+ return result;
}
- const statusCode = response.status || 401
- const result = NextResponse.json({ success: false, error: errorCode, needMfa: false }, { status: statusCode })
- clearSessionCookie(result)
- clearMfaCookie(result)
- return result
+ const statusCode = response.status || 401;
+ const result = NextResponse.json(
+ { success: false, error: errorCode, needMfa: false },
+ { status: statusCode },
+ );
+ clearSessionCookie(result, request.headers.get("host") ?? undefined);
+ clearMfaCookie(result);
+ return result;
} catch (error) {
- console.error('Account service login proxy failed', error)
- const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: false }, { status: 502 })
- clearSessionCookie(result)
- clearMfaCookie(result)
- return result
+ console.error("Account service login proxy failed", error);
+ const result = NextResponse.json(
+ { success: false, error: "account_service_unreachable", needMfa: false },
+ { status: 502 },
+ );
+ clearSessionCookie(result, request.headers.get("host") ?? undefined);
+ clearMfaCookie(result);
+ return result;
}
}
export function GET() {
return NextResponse.json(
- { success: false, error: 'method_not_allowed', needMfa: false },
+ { success: false, error: "method_not_allowed", needMfa: false },
{
status: 405,
headers: {
- Allow: 'POST',
+ Allow: "POST",
},
},
- )
+ );
}
export async function DELETE() {
- const cookieStore = await cookies()
- const response = NextResponse.json({ success: true, error: null, needMfa: false })
+ const cookieStore = await cookies();
+ const response = NextResponse.json({
+ success: true,
+ error: null,
+ needMfa: false,
+ });
if (cookieStore.has(MFA_COOKIE_NAME)) {
- clearMfaCookie(response)
+ clearMfaCookie(response);
}
- clearSessionCookie(response)
- return response
+ clearSessionCookie(response);
+ return response;
}
diff --git a/src/app/api/auth/mfa/disable/route.ts b/src/app/api/auth/mfa/disable/route.ts
index 718bbad..c9606ef 100644
--- a/src/app/api/auth/mfa/disable/route.ts
+++ b/src/app/api/auth/mfa/disable/route.ts
@@ -1,54 +1,66 @@
-import { cookies } from 'next/headers'
-import { NextRequest, NextResponse } from 'next/server'
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
-import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
-import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
+import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
+import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export async function POST(request: NextRequest) {
- void request
- const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim()
+ void request;
+ const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
if (!token) {
- return NextResponse.json({ success: false, error: 'session_required' }, { status: 401 })
+ return NextResponse.json(
+ { success: false, error: "session_required" },
+ { status: 401 },
+ );
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/disable`, {
- method: 'POST',
+ method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
- const data = await response.json().catch(() => ({}))
+ const data = await response.json().catch(() => ({}));
if (!response.ok) {
- const errorCode = typeof (data as { error?: string })?.error === 'string' ? data.error : 'mfa_disable_failed'
+ const errorCode =
+ typeof (data as { error?: string })?.error === "string"
+ ? data.error
+ : "mfa_disable_failed";
if (response.status === 401) {
- const result = NextResponse.json({ success: false, error: errorCode })
- clearSessionCookie(result)
- return result
+ const result = NextResponse.json({ success: false, error: errorCode });
+ clearSessionCookie(result, request.headers.get("host") ?? undefined);
+ return result;
}
- return NextResponse.json({ success: false, error: errorCode }, { status: response.status || 400 })
+ return NextResponse.json(
+ { success: false, error: errorCode },
+ { status: response.status || 400 },
+ );
}
- return NextResponse.json({ success: true, error: null, data })
+ return NextResponse.json({ success: true, error: null, data });
} catch (error) {
- console.error('Account service MFA disable proxy failed', error)
- return NextResponse.json({ success: false, error: 'account_service_unreachable' }, { status: 502 })
+ console.error("Account service MFA disable proxy failed", error);
+ return NextResponse.json(
+ { success: false, error: "account_service_unreachable" },
+ { status: 502 },
+ );
}
}
export function GET() {
return NextResponse.json(
- { success: false, error: 'method_not_allowed' },
+ { success: false, error: "method_not_allowed" },
{
status: 405,
headers: {
- Allow: 'POST',
+ Allow: "POST",
},
},
- )
+ );
}
diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts
index 1cd40d6..a57789b 100644
--- a/src/app/api/auth/mfa/verify/route.ts
+++ b/src/app/api/auth/mfa/verify/route.ts
@@ -1,5 +1,5 @@
-import { cookies } from 'next/headers'
-import { NextRequest, NextResponse } from 'next/server'
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
import {
applyMfaCookie,
@@ -8,107 +8,136 @@ import {
clearSessionCookie,
deriveMaxAgeFromExpires,
MFA_COOKIE_NAME,
-} from '@lib/authGateway'
-import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
+} from "@lib/authGateway";
+import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
type VerifyPayload = {
- token?: string
- code?: string
- totp?: string
-}
+ token?: string;
+ code?: string;
+ totp?: string;
+};
type AccountVerifyResponse = {
- token?: string
- expiresAt?: string
- mfaToken?: string
- error?: string
- retryAt?: string
- user?: Record | null
- mfa?: Record | null
-}
+ token?: string;
+ expiresAt?: string;
+ mfaToken?: string;
+ error?: string;
+ retryAt?: string;
+ user?: Record | null;
+ mfa?: Record | null;
+};
function normalizeString(value: unknown) {
- return typeof value === 'string' ? value.trim() : ''
+ return typeof value === "string" ? value.trim() : "";
}
function normalizeCode(value: unknown) {
- return typeof value === 'string' ? value.replace(/\D/g, '').slice(0, 6) : ''
+ return typeof value === "string" ? value.replace(/\D/g, "").slice(0, 6) : "";
}
export async function POST(request: NextRequest) {
- const cookieStore = await cookies()
- let payload: VerifyPayload
+ const cookieStore = await cookies();
+ let payload: VerifyPayload;
try {
- payload = (await request.json()) as VerifyPayload
+ payload = (await request.json()) as VerifyPayload;
} catch (error) {
- console.error('Failed to decode MFA verification payload', error)
- return NextResponse.json({ success: false, error: 'invalid_request', needMfa: true }, { status: 400 })
+ console.error("Failed to decode MFA verification payload", error);
+ return NextResponse.json(
+ { success: false, error: "invalid_request", needMfa: true },
+ { status: 400 },
+ );
}
- const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? ''
- const token = normalizeString(payload?.token || cookieToken)
- const code = normalizeCode(payload?.code ?? payload?.totp)
+ const cookieToken = cookieStore.get(MFA_COOKIE_NAME)?.value ?? "";
+ const token = normalizeString(payload?.token || cookieToken);
+ const code = normalizeCode(payload?.code ?? payload?.totp);
if (!token) {
- return NextResponse.json({ success: false, error: 'mfa_token_required', needMfa: true }, { status: 400 })
+ return NextResponse.json(
+ { success: false, error: "mfa_token_required", needMfa: true },
+ { status: 400 },
+ );
}
if (!code) {
- return NextResponse.json({ success: false, error: 'mfa_code_required', needMfa: true }, { status: 400 })
+ return NextResponse.json(
+ { success: false, error: "mfa_code_required", needMfa: true },
+ { status: 400 },
+ );
}
try {
const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, {
- method: 'POST',
+ method: "POST",
headers: {
- 'Content-Type': 'application/json',
+ "Content-Type": "application/json",
},
body: JSON.stringify({ token, code }),
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
- const data = (await response.json().catch(() => ({}))) as AccountVerifyResponse
+ const data = (await response
+ .json()
+ .catch(() => ({}))) as AccountVerifyResponse;
- if (response.ok && typeof data?.token === 'string' && data.token.length > 0) {
- const result = NextResponse.json({ success: true, error: null, needMfa: false, data })
- applySessionCookie(result, data.token, deriveMaxAgeFromExpires(data?.expiresAt))
- clearMfaCookie(result)
- return result
+ if (
+ response.ok &&
+ typeof data?.token === "string" &&
+ data.token.length > 0
+ ) {
+ const result = NextResponse.json({
+ success: true,
+ error: null,
+ needMfa: false,
+ data,
+ });
+ applySessionCookie(
+ result,
+ data.token,
+ deriveMaxAgeFromExpires(data?.expiresAt),
+ request.headers.get("host") ?? undefined,
+ );
+ clearMfaCookie(result);
+ return result;
}
- const errorCode = typeof data?.error === 'string' ? data.error : 'mfa_verification_failed'
+ const errorCode =
+ typeof data?.error === "string" ? data.error : "mfa_verification_failed";
const result = NextResponse.json(
{ success: false, error: errorCode, needMfa: true, data },
{ status: response.status || 400 },
- )
+ );
- if (typeof data?.mfaToken === 'string' && data.mfaToken.trim()) {
- applyMfaCookie(result, data.mfaToken)
+ if (typeof data?.mfaToken === "string" && data.mfaToken.trim()) {
+ applyMfaCookie(result, data.mfaToken);
} else {
- applyMfaCookie(result, token)
+ applyMfaCookie(result, token);
}
- clearSessionCookie(result)
- return result
+ clearSessionCookie(result, request.headers.get("host") ?? undefined);
+ return result;
} catch (error) {
- console.error('Account service MFA verification proxy failed', error)
- const result = NextResponse.json({ success: false, error: 'account_service_unreachable', needMfa: true }, { status: 502 })
- applyMfaCookie(result, token)
- clearSessionCookie(result)
- return result
+ console.error("Account service MFA verification proxy failed", error);
+ const result = NextResponse.json(
+ { success: false, error: "account_service_unreachable", needMfa: true },
+ { status: 502 },
+ );
+ applyMfaCookie(result, token);
+ clearSessionCookie(result, request.headers.get("host") ?? undefined);
+ return result;
}
}
export function GET() {
return NextResponse.json(
- { success: false, error: 'method_not_allowed', needMfa: true },
+ { success: false, error: "method_not_allowed", needMfa: true },
{
status: 405,
headers: {
- Allow: 'POST',
+ Allow: "POST",
},
},
- )
+ );
}
diff --git a/src/app/api/auth/oauth/login/[provider]/route.ts b/src/app/api/auth/oauth/login/[provider]/route.ts
new file mode 100644
index 0000000..302d1ac
--- /dev/null
+++ b/src/app/api/auth/oauth/login/[provider]/route.ts
@@ -0,0 +1,24 @@
+import { NextRequest, NextResponse } from "next/server";
+
+import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
+
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
+const ALLOWED_PROVIDERS = new Set(["github", "google"]);
+
+export async function GET(
+ request: NextRequest,
+ context: { params: Promise<{ provider: string }> },
+) {
+ const { provider } = await context.params;
+ const normalizedProvider = provider.trim().toLowerCase();
+ if (!ALLOWED_PROVIDERS.has(normalizedProvider)) {
+ return NextResponse.json({ error: "provider_not_found" }, { status: 404 });
+ }
+
+ const target = new URL(
+ `${ACCOUNT_API_BASE}/oauth/login/${normalizedProvider}`,
+ );
+ target.searchParams.set("frontend_url", request.nextUrl.origin);
+
+ return NextResponse.redirect(target, { status: 307 });
+}
diff --git a/src/app/api/auth/session/route.ts b/src/app/api/auth/session/route.ts
index 58f39b1..4fbebac 100644
--- a/src/app/api/auth/session/route.ts
+++ b/src/app/api/auth/session/route.ts
@@ -1,116 +1,131 @@
-import { cookies } from 'next/headers'
-import { NextRequest, NextResponse } from 'next/server'
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
-import { SESSION_COOKIE_NAME, clearSessionCookie } from '@lib/authGateway'
-import { getAccountServiceApiBaseUrl, getAccountServiceBaseUrl } from '@server/serviceConfig'
-import { buildInternalServiceHeaders, isServiceTokenConfigured } from '@server/internalServiceAuth'
+import { SESSION_COOKIE_NAME, clearSessionCookie } from "@lib/authGateway";
+import {
+ getAccountServiceApiBaseUrl,
+ getAccountServiceBaseUrl,
+} from "@server/serviceConfig";
+import {
+ buildInternalServiceHeaders,
+ isServiceTokenConfigured,
+} from "@server/internalServiceAuth";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
-const ACCOUNT_BASE = getAccountServiceBaseUrl()
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
+const ACCOUNT_BASE = getAccountServiceBaseUrl();
type AccountUser = {
- id?: string
- uuid?: string
- proxyUuid?: string
- proxyUuidExpiresAt?: string
- name?: string
- username?: string
- email: string
- mfaEnabled?: boolean
- mfaPending?: boolean
+ id?: string;
+ uuid?: string;
+ proxyUuid?: string;
+ proxyUuidExpiresAt?: string;
+ name?: string;
+ username?: string;
+ email: string;
+ mfaEnabled?: boolean;
+ mfaPending?: boolean;
mfa?: {
- totpEnabled?: boolean
- totpPending?: boolean
- totpSecretIssuedAt?: string
- totpConfirmedAt?: string
- totpLockedUntil?: string
- }
- role?: string
- groups?: string[]
- permissions?: string[]
- readOnly?: boolean
- tenantId?: string
+ totpEnabled?: boolean;
+ totpPending?: boolean;
+ totpSecretIssuedAt?: string;
+ totpConfirmedAt?: string;
+ totpLockedUntil?: string;
+ };
+ role?: string;
+ groups?: string[];
+ permissions?: string[];
+ readOnly?: boolean;
+ tenantId?: string;
tenants?: Array<{
- id?: string
- name?: string
- role?: string
- }>
-}
+ id?: string;
+ name?: string;
+ role?: string;
+ }>;
+};
type SessionResponse = {
- user?: AccountUser | null
- error?: string
-}
+ user?: AccountUser | null;
+ error?: string;
+};
type SandboxGuestResponse = {
- email?: string
- proxyUuid?: string
- proxyUuidExpiresAt?: string
- error?: string
-}
+ email?: string;
+ proxyUuid?: string;
+ proxyUuidExpiresAt?: string;
+ error?: string;
+};
function normalizeRole(role: unknown): string {
- if (typeof role !== 'string') {
- return 'user'
+ if (typeof role !== "string") {
+ return "user";
}
- const normalized = role.trim().toLowerCase()
+ const normalized = role.trim().toLowerCase();
if (!normalized) {
- return 'user'
+ return "user";
}
- if (normalized === 'root' || normalized === 'super_admin') {
- return 'admin'
+ if (normalized === "root" || normalized === "super_admin") {
+ return "admin";
}
- if (normalized === 'readonly' || normalized === 'read_only') {
- return 'user'
+ if (normalized === "readonly" || normalized === "read_only") {
+ return "user";
}
- return normalized
+ return normalized;
}
-async function fetchSession(token: string) {
+async function fetchSession(token: string, requestHost?: string | null) {
try {
const response = await fetch(`${ACCOUNT_API_BASE}/session`, {
headers: {
Authorization: `Bearer ${token}`,
+ ...(requestHost && requestHost.trim().length > 0
+ ? {
+ "X-Forwarded-Host": requestHost.trim(),
+ }
+ : {}),
},
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
- const data = (await response.json().catch(() => ({}))) as SessionResponse
- return { response, data }
+ const data = (await response.json().catch(() => ({}))) as SessionResponse;
+ return { response, data };
} catch (error) {
- console.error('Session lookup proxy failed', error)
- return { response: null, data: null }
+ console.error("Session lookup proxy failed", error);
+ return { response: null, data: null };
}
}
async function fetchSandboxGuest(): Promise {
if (!isServiceTokenConfigured()) {
- return null
+ return null;
}
try {
const response = await fetch(`${ACCOUNT_BASE}/api/internal/sandbox/guest`, {
- method: 'GET',
+ method: "GET",
headers: buildInternalServiceHeaders({
- Accept: 'application/json',
+ Accept: "application/json",
}),
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
if (!response.ok) {
- return null
+ return null;
}
- const payload = (await response.json().catch(() => null)) as SandboxGuestResponse | null
- const proxyUuid = typeof payload?.proxyUuid === 'string' ? payload.proxyUuid.trim() : ''
+ const payload = (await response
+ .json()
+ .catch(() => null)) as SandboxGuestResponse | null;
+ const proxyUuid =
+ typeof payload?.proxyUuid === "string" ? payload.proxyUuid.trim() : "";
if (!proxyUuid) {
- return null
+ return null;
}
const proxyUuidExpiresAt =
- typeof payload?.proxyUuidExpiresAt === 'string' && payload.proxyUuidExpiresAt.trim().length > 0
+ typeof payload?.proxyUuidExpiresAt === "string" &&
+ payload.proxyUuidExpiresAt.trim().length > 0
? payload.proxyUuidExpiresAt.trim()
- : undefined
+ : undefined;
// Shape this as a pseudo-session user for the Guest/Demo experience.
return {
@@ -118,135 +133,162 @@ async function fetchSandboxGuest(): Promise {
uuid: proxyUuid,
proxyUuid,
proxyUuidExpiresAt,
- name: 'Guest user',
- username: 'guest',
- email: 'sandbox@svc.plus',
- role: 'guest',
- groups: ['guest', 'sandbox'],
- permissions: ['read'],
+ name: "Guest user",
+ username: "guest",
+ email: "sandbox@svc.plus",
+ role: "guest",
+ groups: ["guest", "sandbox"],
+ permissions: ["read"],
readOnly: true,
- tenantId: 'guest-sandbox',
- tenants: [{ id: 'guest-sandbox', name: 'Guest Sandbox', role: 'guest' }],
+ tenantId: "guest-sandbox",
+ tenants: [{ id: "guest-sandbox", name: "Guest Sandbox", role: "guest" }],
mfaEnabled: false,
mfaPending: false,
- }
+ };
} catch (error) {
- console.error('Sandbox guest session proxy failed', error)
- return null
+ console.error("Sandbox guest session proxy failed", error);
+ return null;
}
}
export async function GET(request: NextRequest) {
- void request
- const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value
+ void request;
+ const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value;
if (!token) {
- const sandboxGuest = await fetchSandboxGuest()
- return NextResponse.json({ user: sandboxGuest })
+ const sandboxGuest = await fetchSandboxGuest();
+ return NextResponse.json({ user: sandboxGuest });
}
- const { response, data } = await fetchSession(token)
+ const requestHost = request.headers.get("host");
+ const { response, data } = await fetchSession(token, requestHost);
if (!response || !response.ok || !data?.user) {
- const res = NextResponse.json({ user: null })
- clearSessionCookie(res)
- return res
+ const res = NextResponse.json({ user: null });
+ clearSessionCookie(res, requestHost ?? undefined);
+ return res;
}
- const rawUser = data.user as AccountUser
+ const rawUser = data.user as AccountUser;
const identifier =
- typeof rawUser.uuid === 'string' && rawUser.uuid.trim().length > 0
+ typeof rawUser.uuid === "string" && rawUser.uuid.trim().length > 0
? rawUser.uuid.trim()
- : typeof rawUser.id === 'string'
+ : typeof rawUser.id === "string"
? rawUser.id.trim()
- : undefined
+ : undefined;
- const rawMfa = rawUser.mfa ?? {}
- const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled)
+ const rawMfa = rawUser.mfa ?? {};
+ const derivedMfaEnabled = Boolean(rawUser.mfaEnabled ?? rawMfa.totpEnabled);
const derivedMfaPendingSource =
- typeof rawUser.mfaPending === 'boolean'
+ typeof rawUser.mfaPending === "boolean"
? rawUser.mfaPending
- : typeof rawMfa.totpPending === 'boolean'
+ : typeof rawMfa.totpPending === "boolean"
? rawMfa.totpPending
- : false
- const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled
+ : false;
+ const derivedMfaPending = derivedMfaPendingSource && !derivedMfaEnabled;
- const normalizedRole = normalizeRole(rawUser.role)
- const rawRole = typeof rawUser.role === 'string' ? rawUser.role.trim().toLowerCase() : ''
+ const normalizedRole = normalizeRole(rawUser.role);
+ const rawRole =
+ typeof rawUser.role === "string" ? rawUser.role.trim().toLowerCase() : "";
const normalizedGroups = Array.isArray(rawUser.groups)
? rawUser.groups
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
- .map((value) => value.trim())
- : []
+ .filter(
+ (value): value is string =>
+ typeof value === "string" && value.trim().length > 0,
+ )
+ .map((value) => value.trim())
+ : [];
const normalizedPermissions = Array.isArray(rawUser.permissions)
? rawUser.permissions
- .filter((value): value is string => typeof value === 'string' && value.trim().length > 0)
- .map((value) => value.trim())
- : []
- const normalizedUsernameLower = String(rawUser.username ?? '').trim().toLowerCase()
- const normalizedNameLower = String(rawUser.name ?? '').trim().toLowerCase()
- const identifierLower = (identifier ?? '').toLowerCase()
+ .filter(
+ (value): value is string =>
+ typeof value === "string" && value.trim().length > 0,
+ )
+ .map((value) => value.trim())
+ : [];
+ const normalizedUsernameLower = String(rawUser.username ?? "")
+ .trim()
+ .toLowerCase();
+ const normalizedNameLower = String(rawUser.name ?? "")
+ .trim()
+ .toLowerCase();
+ const identifierLower = (identifier ?? "").toLowerCase();
const normalizedReadOnly =
Boolean(rawUser.readOnly) ||
- normalizedGroups.some((group) => group.toLowerCase() === 'readonly role') ||
- rawRole === 'readonly' ||
- rawRole === 'read_only' ||
- String(rawUser.email ?? '').trim().toLowerCase() === 'sandbox@svc.plus'
+ normalizedGroups.some((group) => group.toLowerCase() === "readonly role") ||
+ rawRole === "readonly" ||
+ rawRole === "read_only" ||
+ String(rawUser.email ?? "")
+ .trim()
+ .toLowerCase() === "sandbox@svc.plus";
const normalizedProxyUuid =
- typeof rawUser.proxyUuid === 'string' && rawUser.proxyUuid.trim().length > 0
+ typeof rawUser.proxyUuid === "string" && rawUser.proxyUuid.trim().length > 0
? rawUser.proxyUuid.trim()
- : undefined
+ : undefined;
const normalizedProxyUuidExpiresAt =
- typeof rawUser.proxyUuidExpiresAt === 'string' && rawUser.proxyUuidExpiresAt.trim().length > 0
+ typeof rawUser.proxyUuidExpiresAt === "string" &&
+ rawUser.proxyUuidExpiresAt.trim().length > 0
? rawUser.proxyUuidExpiresAt.trim()
- : undefined
+ : undefined;
const normalizedTenantId =
- typeof rawUser.tenantId === 'string' && rawUser.tenantId.trim().length > 0
+ typeof rawUser.tenantId === "string" && rawUser.tenantId.trim().length > 0
? rawUser.tenantId.trim()
- : undefined
+ : undefined;
const normalizedTenants = Array.isArray(rawUser.tenants)
? rawUser.tenants
- .map((tenant) => {
- if (!tenant || typeof tenant !== 'object') {
- return null
- }
+ .map((tenant) => {
+ if (!tenant || typeof tenant !== "object") {
+ return null;
+ }
- const identifier =
- typeof tenant.id === 'string' && tenant.id.trim().length > 0
- ? tenant.id.trim()
- : undefined
- if (!identifier) {
- return null
- }
+ const identifier =
+ typeof tenant.id === "string" && tenant.id.trim().length > 0
+ ? tenant.id.trim()
+ : undefined;
+ if (!identifier) {
+ return null;
+ }
- const normalizedTenant: { id: string; name?: string; role?: string } = {
- id: identifier,
- }
+ const normalizedTenant: { id: string; name?: string; role?: string } =
+ {
+ id: identifier,
+ };
- if (typeof tenant.name === 'string' && tenant.name.trim().length > 0) {
- normalizedTenant.name = tenant.name.trim()
- }
+ if (
+ typeof tenant.name === "string" &&
+ tenant.name.trim().length > 0
+ ) {
+ normalizedTenant.name = tenant.name.trim();
+ }
- if (typeof tenant.role === 'string' && tenant.role.trim().length > 0) {
- normalizedTenant.role = tenant.role.trim().toLowerCase()
- }
+ if (
+ typeof tenant.role === "string" &&
+ tenant.role.trim().length > 0
+ ) {
+ normalizedTenant.role = tenant.role.trim().toLowerCase();
+ }
- return normalizedTenant
- })
- .filter((tenant): tenant is { id: string; name?: string; role?: string } => Boolean(tenant))
- : undefined
+ return normalizedTenant;
+ })
+ .filter(
+ (tenant): tenant is { id: string; name?: string; role?: string } =>
+ Boolean(tenant),
+ )
+ : undefined;
const normalizedMfa = Object.keys(rawMfa).length
? {
- ...rawMfa,
- totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
- totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
- }
+ ...rawMfa,
+ totpEnabled: Boolean(rawMfa.totpEnabled ?? derivedMfaEnabled),
+ totpPending: Boolean(rawMfa.totpPending ?? derivedMfaPending),
+ }
: {
- totpEnabled: derivedMfaEnabled,
- totpPending: derivedMfaPending,
- }
+ totpEnabled: derivedMfaEnabled,
+ totpPending: derivedMfaPending,
+ };
- const normalizedUser = identifier ? { ...rawUser, id: identifier, uuid: identifier } : rawUser
+ const normalizedUser = identifier
+ ? { ...rawUser, id: identifier, uuid: identifier }
+ : rawUser;
return NextResponse.json({
user: {
@@ -263,24 +305,24 @@ export async function GET(request: NextRequest) {
tenantId: normalizedTenantId,
tenants: normalizedTenants,
},
- })
+ });
}
export async function DELETE(request: NextRequest) {
- void request
- const cookieStore = await cookies()
- const token = cookieStore.get(SESSION_COOKIE_NAME)?.value
+ void request;
+ const cookieStore = await cookies();
+ const token = cookieStore.get(SESSION_COOKIE_NAME)?.value;
if (token) {
await fetch(`${ACCOUNT_API_BASE}/session`, {
- method: 'DELETE',
+ method: "DELETE",
headers: {
Authorization: `Bearer ${token}`,
},
- cache: 'no-store',
- }).catch(() => null)
+ cache: "no-store",
+ }).catch(() => null);
}
- const response = NextResponse.json({ success: true })
- clearSessionCookie(response)
- return response
+ const response = NextResponse.json({ success: true });
+ clearSessionCookie(response, request.headers.get("host") ?? undefined);
+ return response;
}
diff --git a/src/app/api/auth/token/exchange/route.ts b/src/app/api/auth/token/exchange/route.ts
index 7b506f2..45ae789 100644
--- a/src/app/api/auth/token/exchange/route.ts
+++ b/src/app/api/auth/token/exchange/route.ts
@@ -1,53 +1,74 @@
-import { NextRequest, NextResponse } from 'next/server'
-import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
-import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
+import { NextRequest, NextResponse } from "next/server";
+import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
+import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
export async function POST(request: NextRequest) {
- try {
- const payload = await request.json()
- const { exchangeCode } = payload
+ try {
+ const payload = await request.json();
+ const { exchangeCode } = payload;
- if (!exchangeCode || typeof exchangeCode !== 'string') {
- return NextResponse.json({ success: false, error: 'invalid_request' }, { status: 400 })
- }
-
- const response = await fetch(`${ACCOUNT_API_BASE}/token/exchange`, {
- method: 'POST',
- headers: {
- 'Content-Type': 'application/json',
- },
- body: JSON.stringify({
- exchange_code: exchangeCode,
- }),
- cache: 'no-store',
- })
-
- if (!response.ok) {
- const errorData = await response.json().catch(() => ({}))
- return NextResponse.json({ success: false, error: errorData.error || 'exchange_failed' }, { status: response.status })
- }
-
- const data = await response.json()
- const sessionToken = typeof data.token === 'string' && data.token.trim().length > 0
- ? data.token.trim()
- : typeof data.access_token === 'string' && data.access_token.trim().length > 0
- ? data.access_token.trim()
- : ''
-
- if (!sessionToken) {
- return NextResponse.json({ success: false, error: 'invalid_response' }, { status: 502 })
- }
-
- const result = NextResponse.json({ success: true })
- const maxAge =
- typeof data.expires_in === 'number' ? data.expires_in : deriveMaxAgeFromExpires(data.expiresAt)
- applySessionCookie(result, sessionToken, maxAge)
-
- return result
- } catch (error) {
- console.error('Token exchange proxy failed', error)
- return NextResponse.json({ success: false, error: 'internal_error' }, { status: 500 })
+ if (!exchangeCode || typeof exchangeCode !== "string") {
+ return NextResponse.json(
+ { success: false, error: "invalid_request" },
+ { status: 400 },
+ );
}
+
+ const response = await fetch(`${ACCOUNT_API_BASE}/token/exchange`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify({
+ exchange_code: exchangeCode,
+ }),
+ cache: "no-store",
+ });
+
+ if (!response.ok) {
+ const errorData = await response.json().catch(() => ({}));
+ return NextResponse.json(
+ { success: false, error: errorData.error || "exchange_failed" },
+ { status: response.status },
+ );
+ }
+
+ const data = await response.json();
+ const sessionToken =
+ typeof data.token === "string" && data.token.trim().length > 0
+ ? data.token.trim()
+ : typeof data.access_token === "string" &&
+ data.access_token.trim().length > 0
+ ? data.access_token.trim()
+ : "";
+
+ if (!sessionToken) {
+ return NextResponse.json(
+ { success: false, error: "invalid_response" },
+ { status: 502 },
+ );
+ }
+
+ const result = NextResponse.json({ success: true });
+ const maxAge =
+ typeof data.expires_in === "number"
+ ? data.expires_in
+ : deriveMaxAgeFromExpires(data.expiresAt);
+ applySessionCookie(
+ result,
+ sessionToken,
+ maxAge,
+ request.headers.get("host") ?? undefined,
+ );
+
+ return result;
+ } catch (error) {
+ console.error("Token exchange proxy failed", error);
+ return NextResponse.json(
+ { success: false, error: "internal_error" },
+ { status: 500 },
+ );
+ }
}
diff --git a/src/app/api/sandbox/assume/revert/route.ts b/src/app/api/sandbox/assume/revert/route.ts
index 4f1cec0..e9c9d0b 100644
--- a/src/app/api/sandbox/assume/revert/route.ts
+++ b/src/app/api/sandbox/assume/revert/route.ts
@@ -1,82 +1,97 @@
-export const dynamic = 'force-dynamic'
+export const dynamic = "force-dynamic";
-import { NextRequest, NextResponse } from 'next/server'
+import { NextRequest, NextResponse } from "next/server";
-import { applySessionCookie } from '@lib/authGateway'
-import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
+import { applySessionCookie } from "@lib/authGateway";
+import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
-const ROOT_BACKUP_COOKIE = 'xc_session_root'
+const ROOT_BACKUP_COOKIE = "xc_session_root";
type ErrorPayload = {
- error: string
-}
+ error: string;
+};
function secureCookies(): boolean {
- if (process.env.NODE_ENV === 'production') {
- return true
+ if (process.env.NODE_ENV === "production") {
+ return true;
}
- const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
- return baseUrl.toLowerCase().startsWith('https://')
+ const baseUrl =
+ process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
+ return baseUrl.toLowerCase().startsWith("https://");
}
async function verifyRootToken(token: string): Promise {
try {
const res = await fetch(`${ACCOUNT_API_BASE}/session`, {
- method: 'GET',
+ method: "GET",
headers: {
Authorization: `Bearer ${token}`,
- Accept: 'application/json',
+ Accept: "application/json",
},
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
if (!res.ok) {
- return false
+ return false;
}
- const payload = (await res.json().catch(() => null)) as any
- const email = typeof payload?.user?.email === 'string' ? payload.user.email.trim().toLowerCase() : ''
- return email === 'admin@svc.plus'
+ const payload = (await res.json().catch(() => null)) as any;
+ const email =
+ typeof payload?.user?.email === "string"
+ ? payload.user.email.trim().toLowerCase()
+ : "";
+ return email === "admin@svc.plus";
} catch {
- return false
+ return false;
}
}
export async function POST(request: NextRequest) {
- const rootToken = request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? ''
+ const rootToken =
+ request.cookies.get(ROOT_BACKUP_COOKIE)?.value?.trim() ?? "";
if (!rootToken) {
- return NextResponse.json({ error: 'not_assuming' }, { status: 400 })
+ return NextResponse.json(
+ { error: "not_assuming" },
+ { status: 400 },
+ );
}
if (!(await verifyRootToken(rootToken))) {
- return NextResponse.json({ error: 'root_token_invalid' }, { status: 403 })
+ return NextResponse.json(
+ { error: "root_token_invalid" },
+ { status: 403 },
+ );
}
// Best-effort audit log on accounts.svc.plus. (Cookies are owned by console.)
try {
await fetch(`${ACCOUNT_API_BASE}/admin/assume/revert`, {
- method: 'POST',
+ method: "POST",
headers: {
Authorization: `Bearer ${rootToken}`,
- Accept: 'application/json',
+ Accept: "application/json",
},
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
} catch (error) {
- console.error('Failed to audit assume revert', error)
+ console.error("Failed to audit assume revert", error);
}
- const response = NextResponse.json({ ok: true })
- applySessionCookie(response, rootToken)
+ const response = NextResponse.json({ ok: true });
+ applySessionCookie(
+ response,
+ rootToken,
+ undefined,
+ request.headers.get("host") ?? undefined,
+ );
response.cookies.set({
name: ROOT_BACKUP_COOKIE,
- value: '',
+ value: "",
httpOnly: true,
secure: secureCookies(),
- sameSite: 'lax',
- path: '/',
+ sameSite: "lax",
+ path: "/",
maxAge: 0,
- })
- return response
+ });
+ return response;
}
-
diff --git a/src/app/api/sandbox/assume/route.ts b/src/app/api/sandbox/assume/route.ts
index 8007147..d1594dc 100644
--- a/src/app/api/sandbox/assume/route.ts
+++ b/src/app/api/sandbox/assume/route.ts
@@ -1,76 +1,90 @@
-export const dynamic = 'force-dynamic'
+export const dynamic = "force-dynamic";
-import { NextRequest, NextResponse } from 'next/server'
+import { NextRequest, NextResponse } from "next/server";
-import { applySessionCookie, deriveMaxAgeFromExpires } from '@lib/authGateway'
-import { evaluateAccountAdminAccess } from '@server/account/adminAccess'
-import { getAccountServiceApiBaseUrl } from '@server/serviceConfig'
-import { getAccountSession } from '@server/account/session'
-import type { AccountUserRole } from '@server/account/session'
+import { applySessionCookie, deriveMaxAgeFromExpires } from "@lib/authGateway";
+import { evaluateAccountAdminAccess } from "@server/account/adminAccess";
+import { getAccountServiceApiBaseUrl } from "@server/serviceConfig";
+import { getAccountSession } from "@server/account/session";
+import type { AccountUserRole } from "@server/account/session";
-const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl()
-const REQUIRED_ROLES: AccountUserRole[] = ['admin']
-const WRITE_PERMISSIONS = ['admin.settings.write']
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
+const REQUIRED_ROLES: AccountUserRole[] = ["admin"];
+const WRITE_PERMISSIONS = ["admin.settings.write"];
-const ROOT_BACKUP_COOKIE = 'xc_session_root'
-const SANDBOX_EMAIL = 'sandbox@svc.plus'
+const ROOT_BACKUP_COOKIE = "xc_session_root";
+const SANDBOX_EMAIL = "sandbox@svc.plus";
type ErrorPayload = {
- error: string
-}
+ error: string;
+};
function secureCookies(): boolean {
- if (process.env.NODE_ENV === 'production') {
- return true
+ if (process.env.NODE_ENV === "production") {
+ return true;
}
- const baseUrl = process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || ''
- return baseUrl.toLowerCase().startsWith('https://')
+ const baseUrl =
+ process.env.NEXT_PUBLIC_APP_BASE_URL || process.env.APP_BASE_URL || "";
+ return baseUrl.toLowerCase().startsWith("https://");
}
export async function POST(request: NextRequest) {
- const session = await getAccountSession(request)
- const user = session.user
+ const session = await getAccountSession(request);
+ const user = session.user;
if (!user || !session.token) {
- return NextResponse.json({ error: 'unauthenticated' }, { status: 401 })
+ return NextResponse.json(
+ { error: "unauthenticated" },
+ { status: 401 },
+ );
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
- })
+ });
if (!access.allowed) {
- return NextResponse.json({ error: access.reason ?? 'forbidden' }, { status: 403 })
+ return NextResponse.json(
+ { error: access.reason ?? "forbidden" },
+ { status: 403 },
+ );
}
try {
const upstream = await fetch(`${ACCOUNT_API_BASE}/admin/assume`, {
- method: 'POST',
+ method: "POST",
headers: {
Authorization: `Bearer ${session.token}`,
- Accept: 'application/json',
- 'Content-Type': 'application/json',
+ Accept: "application/json",
+ "Content-Type": "application/json",
},
body: JSON.stringify({ email: SANDBOX_EMAIL }),
- cache: 'no-store',
- })
+ cache: "no-store",
+ });
- const contentType = upstream.headers.get('content-type') ?? ''
- if (!contentType.toLowerCase().includes('application/json')) {
- const text = await upstream.text().catch(() => '')
+ const contentType = upstream.headers.get("content-type") ?? "";
+ if (!contentType.toLowerCase().includes("application/json")) {
+ const text = await upstream.text().catch(() => "");
return NextResponse.json(
- { error: 'upstream_non_json', upstreamStatus: upstream.status, upstreamBody: text.slice(0, 2048) } as any,
+ {
+ error: "upstream_non_json",
+ upstreamStatus: upstream.status,
+ upstreamBody: text.slice(0, 2048),
+ } as any,
{ status: 502 },
- )
+ );
}
- const payload = (await upstream.json().catch(() => null)) as any
- if (!payload || typeof payload.token !== 'string') {
- return NextResponse.json({ error: 'invalid_response' }, { status: 502 })
+ const payload = (await upstream.json().catch(() => null)) as any;
+ if (!payload || typeof payload.token !== "string") {
+ return NextResponse.json(
+ { error: "invalid_response" },
+ { status: 502 },
+ );
}
- const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL })
+ const response = NextResponse.json({ ok: true, assumed: SANDBOX_EMAIL });
// Backup current root session token only if it's NOT already an assumed session.
// Check if the current user is NOT the sandbox user.
@@ -80,18 +94,26 @@ export async function POST(request: NextRequest) {
value: session.token,
httpOnly: true,
secure: secureCookies(),
- sameSite: 'lax',
- path: '/',
+ sameSite: "lax",
+ path: "/",
maxAge: deriveMaxAgeFromExpires(payload.expiresAt),
- })
+ });
}
// Switch main session to sandbox token.
- applySessionCookie(response, payload.token, deriveMaxAgeFromExpires(payload.expiresAt))
+ applySessionCookie(
+ response,
+ payload.token,
+ deriveMaxAgeFromExpires(payload.expiresAt),
+ request.headers.get("host") ?? undefined,
+ );
- return response
+ return response;
} catch (error) {
- console.error('Failed to assume sandbox', error)
- return NextResponse.json({ error: 'upstream_unreachable' }, { status: 502 })
+ console.error("Failed to assume sandbox", error);
+ return NextResponse.json(
+ { error: "upstream_unreachable" },
+ { status: 502 },
+ );
}
}
diff --git a/src/app/api/xworkmate/profile/route.ts b/src/app/api/xworkmate/profile/route.ts
new file mode 100644
index 0000000..018f027
--- /dev/null
+++ b/src/app/api/xworkmate/profile/route.ts
@@ -0,0 +1,81 @@
+import { cookies } from "next/headers";
+import { NextRequest, NextResponse } from "next/server";
+
+import { SESSION_COOKIE_NAME } from "@/lib/authGateway";
+import { getAccountServiceApiBaseUrl } from "@/server/serviceConfig";
+
+const ACCOUNT_API_BASE = getAccountServiceApiBaseUrl();
+
+function buildProxyHeaders(
+ token: string,
+ requestHost?: string | null,
+): HeadersInit {
+ return {
+ Accept: "application/json",
+ Authorization: `Bearer ${token}`,
+ ...(requestHost && requestHost.trim().length > 0
+ ? {
+ "X-Forwarded-Host": requestHost.trim(),
+ }
+ : {}),
+ };
+}
+
+export async function GET(request: NextRequest) {
+ const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
+ if (!token) {
+ return NextResponse.json(
+ { error: "session_token_required" },
+ { status: 401 },
+ );
+ }
+
+ try {
+ const response = await fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, {
+ method: "GET",
+ headers: buildProxyHeaders(token, request.headers.get("host")),
+ cache: "no-store",
+ });
+
+ const payload = await response.json().catch(() => ({}));
+ return NextResponse.json(payload, { status: response.status });
+ } catch (error) {
+ console.error("xworkmate profile proxy failed", error);
+ return NextResponse.json(
+ { error: "account_service_unreachable" },
+ { status: 502 },
+ );
+ }
+}
+
+export async function PUT(request: NextRequest) {
+ const token = (await cookies()).get(SESSION_COOKIE_NAME)?.value?.trim();
+ if (!token) {
+ return NextResponse.json(
+ { error: "session_token_required" },
+ { status: 401 },
+ );
+ }
+
+ const rawBody = await request.text();
+ try {
+ const response = await fetch(`${ACCOUNT_API_BASE}/xworkmate/profile`, {
+ method: "PUT",
+ headers: {
+ ...buildProxyHeaders(token, request.headers.get("host")),
+ "Content-Type": "application/json",
+ },
+ body: rawBody,
+ cache: "no-store",
+ });
+
+ const payload = await response.json().catch(() => ({}));
+ return NextResponse.json(payload, { status: response.status });
+ } catch (error) {
+ console.error("xworkmate profile update proxy failed", error);
+ return NextResponse.json(
+ { error: "account_service_unreachable" },
+ { status: 502 },
+ );
+ }
+}
diff --git a/src/app/xworkmate/admin/page.tsx b/src/app/xworkmate/admin/page.tsx
new file mode 100644
index 0000000..dff0a3a
--- /dev/null
+++ b/src/app/xworkmate/admin/page.tsx
@@ -0,0 +1,56 @@
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor";
+import {
+ buildSharedXWorkmateUrl,
+ isLegacyConsoleXWorkmateHost,
+ isSharedXWorkmateHost,
+ normalizeXWorkmateHost,
+} from "@/lib/xworkmate/host";
+import { buildXWorkmateScopeKey } from "@/lib/xworkmate/types";
+import { getXWorkmateSessionContext } from "@/server/xworkmate/profile";
+
+export const metadata = {
+ title: "XWorkmate Shared Integrations",
+ description: "Manage the shared XWorkmate integrations profile",
+};
+
+export default async function XWorkmateAdminPage() {
+ const requestHeaders = await headers();
+ const requestHost = normalizeXWorkmateHost(
+ requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
+ );
+
+ if (isLegacyConsoleXWorkmateHost(requestHost)) {
+ redirect(buildSharedXWorkmateUrl("/xworkmate/admin"));
+ }
+
+ const { user, profile } = await getXWorkmateSessionContext(requestHost);
+ if (!profile) {
+ redirect("/xworkmate");
+ }
+ if (!isSharedXWorkmateHost(requestHost)) {
+ redirect("/xworkmate/integrations");
+ }
+ if (
+ profile.profileScope !== "tenant-shared" ||
+ !profile.canEditIntegrations
+ ) {
+ redirect("/xworkmate");
+ }
+
+ const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost);
+
+ return (
+
+ );
+}
diff --git a/src/app/xworkmate/integrations/page.tsx b/src/app/xworkmate/integrations/page.tsx
new file mode 100644
index 0000000..ba03674
--- /dev/null
+++ b/src/app/xworkmate/integrations/page.tsx
@@ -0,0 +1,53 @@
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
+
+import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor";
+import {
+ buildSharedXWorkmateUrl,
+ isLegacyConsoleXWorkmateHost,
+ isSharedXWorkmateHost,
+ normalizeXWorkmateHost,
+} from "@/lib/xworkmate/host";
+import { buildXWorkmateScopeKey } from "@/lib/xworkmate/types";
+import { getXWorkmateSessionContext } from "@/server/xworkmate/profile";
+
+export const metadata = {
+ title: "XWorkmate Personal Integrations",
+ description: "Manage the personal XWorkmate integrations profile",
+};
+
+export default async function XWorkmateIntegrationsPage() {
+ const requestHeaders = await headers();
+ const requestHost = normalizeXWorkmateHost(
+ requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
+ );
+
+ if (isLegacyConsoleXWorkmateHost(requestHost)) {
+ redirect(buildSharedXWorkmateUrl("/xworkmate/integrations"));
+ }
+
+ const { user, profile } = await getXWorkmateSessionContext(requestHost);
+ if (!profile) {
+ redirect("/xworkmate");
+ }
+ if (isSharedXWorkmateHost(requestHost)) {
+ redirect(profile.canEditIntegrations ? "/xworkmate/admin" : "/xworkmate");
+ }
+ if (profile.profileScope !== "user-private") {
+ redirect("/xworkmate");
+ }
+
+ const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost);
+
+ return (
+
+ );
+}
diff --git a/src/app/xworkmate/page.tsx b/src/app/xworkmate/page.tsx
index c25e9c1..60fbe89 100644
--- a/src/app/xworkmate/page.tsx
+++ b/src/app/xworkmate/page.tsx
@@ -1,21 +1,51 @@
import { Suspense } from "react";
+import { headers } from "next/headers";
+import { redirect } from "next/navigation";
import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
+import {
+ buildSharedXWorkmateUrl,
+ isLegacyConsoleXWorkmateHost,
+ normalizeXWorkmateHost,
+} from "@/lib/xworkmate/host";
+import {
+ buildXWorkmateScopeKey,
+ toXWorkmateIntegrationDefaults,
+} from "@/lib/xworkmate/types";
import { getConsoleIntegrationDefaults } from "@/server/consoleIntegrations";
+import { getXWorkmateSessionContext } from "@/server/xworkmate/profile";
export const metadata = {
title: "XWorkmate",
description: "Online XWorkmate workspace powered by OpenClaw gateway",
};
-export default function XWorkmatePage() {
- const defaults = getConsoleIntegrationDefaults();
+export default async function XWorkmatePage() {
+ const requestHeaders = await headers();
+ const requestHost = normalizeXWorkmateHost(
+ requestHeaders.get("x-forwarded-host") ?? requestHeaders.get("host"),
+ );
+
+ if (isLegacyConsoleXWorkmateHost(requestHost)) {
+ redirect(buildSharedXWorkmateUrl("/xworkmate"));
+ }
+
+ const { user, profile } = await getXWorkmateSessionContext(requestHost);
+ const defaults = profile
+ ? toXWorkmateIntegrationDefaults(profile)
+ : getConsoleIntegrationDefaults();
+ const scopeKey = buildXWorkmateScopeKey(profile, user?.id, requestHost);
return (
}>
-
+
);
diff --git a/src/components/xworkmate/XWorkmateProfileEditor.tsx b/src/components/xworkmate/XWorkmateProfileEditor.tsx
new file mode 100644
index 0000000..d16c026
--- /dev/null
+++ b/src/components/xworkmate/XWorkmateProfileEditor.tsx
@@ -0,0 +1,524 @@
+"use client";
+
+import { useEffect, useMemo, useState } from "react";
+import { CheckCircle2, Loader2, RefreshCw, ShieldCheck } from "lucide-react";
+import Link from "next/link";
+
+import type { IntegrationDefaults } from "@/lib/openclaw/types";
+import type { XWorkmateProfileResponse } from "@/lib/xworkmate/types";
+import { toXWorkmateIntegrationDefaults } from "@/lib/xworkmate/types";
+import { useOpenClawConsoleStore } from "@/state/openclawConsoleStore";
+
+type ProbeTarget = "openclaw" | "vault" | "apisix";
+
+type ProbeState = {
+ ok: boolean;
+ status?: number;
+ error?: string;
+ body?: string;
+};
+
+type XWorkmateProfileEditorProps = {
+ payload: XWorkmateProfileResponse;
+ scopeKey: string;
+ workspaceHref: string;
+};
+
+function StatusBadge({ ok, label }: { ok: boolean; label: string }) {
+ return (
+
+
+ {label}
+
+ );
+}
+
+function Field({
+ label,
+ hint,
+ children,
+}: {
+ label: string;
+ hint?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+
+
{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,
+ };
+ },
},
),
-)
+);
From e62df8322cdefc94c16c9c10048eb5edd76c50ea Mon Sep 17 00:00:00 2001
From: Haitao Pan
Date: Tue, 17 Mar 2026 16:22:42 +0800
Subject: [PATCH 02/10] fix(auth): align console MFA proxy with accounts
contract
---
docs/usage/deployment.md | 149 ++++++++++++++++++++++++++-
src/app/api/auth/login/route.ts | 21 +++-
src/app/api/auth/mfa/verify/route.ts | 16 ++-
3 files changed, 173 insertions(+), 13 deletions(-)
diff --git a/docs/usage/deployment.md b/docs/usage/deployment.md
index 8fd0e9e..8a11d05 100644
--- a/docs/usage/deployment.md
+++ b/docs/usage/deployment.md
@@ -1,9 +1,148 @@
-# Deployment
+# Deployment Runbook
-## Purpose
+## Scope
-- TODO: Add content specific to Deployment.
+- Runtime: `console.svc.plus`
+- Frontend host: Vercel
+- Edge: Cloudflare
+- Auth backend: `https://accounts.svc.plus`
-## Notes
+This runbook is the minimum checklist for production incidents where login or MFA stops working and browser devtools show `/api/auth/login` or `/api/auth/mfa/*` failures.
-- TODO: Link to related documents in this section.
+## Expected Request Flow
+
+1. Browser loads `https://console.svc.plus/login`
+2. Browser calls same-origin Next routes on `console.svc.plus`
+3. Next route proxies server-side to `https://accounts.svc.plus/api/auth/*`
+4. `accounts.svc.plus` returns either a session token or an MFA challenge
+
+The browser should not call `accounts.svc.plus` directly for login.
+
+## Fast Triage
+
+Run these checks first:
+
+```bash
+curl -si https://console.svc.plus/login | sed -n '1,20p'
+curl -si https://console.svc.plus/api/auth/login | sed -n '1,20p'
+curl -si https://accounts.svc.plus/healthz | sed -n '1,20p'
+curl -si https://accounts.svc.plus/api/auth/login | sed -n '1,20p'
+```
+
+Interpretation:
+
+- `console.svc.plus` returns `403` with `cf-mitigated: challenge`
+ Cloudflare is blocking the page or auth API before Vercel sees it.
+- `console.svc.plus/api/auth/login` returns `404`
+ Vercel production is not serving the expected Next route, or Cloudflare is pointing at the wrong origin/deployment behavior.
+- `accounts.svc.plus/healthz` fails
+ Back-end outage. Fix backend first.
+- `accounts.svc.plus/api/auth/login` returns `200` with `mfaRequired`
+ Backend is healthy; continue on console/Vercel/Cloudflare.
+
+## Application Checks
+
+Verify the current build still contains the auth routes:
+
+```bash
+cd /Users/shenlan/workspaces/cloud-neutral-toolkit/console.svc.plus
+yarn build
+cat .next/app-path-routes-manifest.json | jq 'with_entries(select(.key|test("/api/auth/")))'
+```
+
+Verify the login page still uses same-origin routes:
+
+```bash
+nl -ba 'src/app/(auth)/login/LoginForm.tsx' | sed -n '64,180p'
+nl -ba 'src/app/api/auth/login/route.ts' | sed -n '1,180p'
+nl -ba 'src/app/api/auth/mfa/verify/route.ts' | sed -n '1,180p'
+```
+
+Expected behavior:
+
+- `LoginForm` posts to `/api/auth/login`
+- login proxy accepts backend `mfaRequired` / `mfaTicket`
+- MFA verify proxy calls `/api/auth/mfa/verify`
+
+## Vercel Checks
+
+In the Vercel project for `console-svc-plus`, verify:
+
+1. The production deployment corresponds to the intended git commit.
+2. Framework preset is `Next.js`.
+3. Build command is `yarn build` or the project default, not a static export command.
+4. Output is not being overridden to static export.
+5. Production Functions include `app/api/auth/login` and the other `app/api/auth/*` handlers.
+6. Required runtime env vars are present for the auth proxy path if they are managed in Vercel.
+
+If the route exists locally but Vercel returns `404`, suspect:
+
+- wrong production deployment selected
+- wrong root directory/project link
+- stale alias or domain assignment
+- build output mismatch between local and Vercel
+
+## Cloudflare Checks
+
+If `curl` shows `cf-mitigated: challenge`, check Cloudflare first.
+
+Look for:
+
+1. Managed Challenge or WAF custom rules affecting `/login`
+2. Managed Challenge or WAF custom rules affecting `/api/auth/*`
+3. Bot Fight Mode or Super Bot Fight Mode interactions
+4. Transform/redirect/cache rules that alter `/api/auth/*`
+5. Page Rules or Ruleset Engine policies applied only to the production hostname
+
+Recommended policy for auth API:
+
+- Do not cache `/api/auth/*`
+- Do not apply JS challenge to `/api/auth/*`
+- Keep standard security headers, but let requests reach Vercel
+
+## Backend Verification
+
+Use the backend directly to prove whether auth is healthy:
+
+```bash
+cd /Users/shenlan/workspaces/cloud-neutral-toolkit/accounts.svc.plus
+set -a; source .env; set +a
+payload=$(printf '{"identifier":"admin@svc.plus","password":"%s"}' "$SUPERADMIN_PASSWORD")
+curl -sS -X POST https://accounts.svc.plus/api/auth/login \
+ -H 'Content-Type: application/json' \
+ -d "$payload"
+```
+
+Expected for an MFA-enabled admin:
+
+- HTTP `200`
+- response contains `mfaRequired`
+- response contains `mfaTicket` or `mfaToken`
+
+## Known Failure Signatures
+
+- `POST https://console.svc.plus/api/auth/login 404`
+ Likely Vercel deployment mismatch or route not published.
+- `403` with `cf-mitigated: challenge`
+ Cloudflare blocked request before Vercel.
+- login returns generic failure even though backend returns MFA challenge
+ Console auth proxy is not parsing MFA fields correctly.
+- MFA code accepted by authenticator but web login still fails
+ Console proxy may be calling the setup endpoint instead of the login MFA endpoint.
+
+## Rollback Strategy
+
+When a release breaks auth:
+
+1. Remove or relax Cloudflare rules affecting `/login` and `/api/auth/*`
+2. Re-point domain to last known-good Vercel production deployment
+3. Roll back `console.svc.plus`
+4. Only then consider `accounts.svc.plus` rollback
+
+## Related Files
+
+- `src/app/(auth)/login/LoginForm.tsx`
+- `src/app/api/auth/login/route.ts`
+- `src/app/api/auth/mfa/status/route.ts`
+- `src/app/api/auth/mfa/verify/route.ts`
+- `src/server/serviceConfig.ts`
diff --git a/src/app/api/auth/login/route.ts b/src/app/api/auth/login/route.ts
index 042553a..159d24b 100644
--- a/src/app/api/auth/login/route.ts
+++ b/src/app/api/auth/login/route.ts
@@ -27,7 +27,9 @@ type AccountLoginResponse = {
expiresAt?: string;
error?: string;
mfaToken?: string;
+ mfaTicket?: string;
needMfa?: boolean;
+ mfaRequired?: boolean;
mfaEnabled?: boolean;
};
@@ -108,22 +110,33 @@ export async function POST(request: NextRequest) {
}
const errorCode =
- typeof data?.error === "string" ? data.error : "authentication_failed";
+ typeof data?.error === "string"
+ ? data.error
+ : data?.mfaRequired
+ ? "mfa_required"
+ : "authentication_failed";
const needsMfa = Boolean(
data?.needMfa ||
+ data?.mfaRequired ||
errorCode === "mfa_required" ||
errorCode === "mfa_setup_required",
);
+ const mfaToken =
+ typeof data?.mfaToken === "string" && data.mfaToken.trim().length > 0
+ ? data.mfaToken
+ : typeof data?.mfaTicket === "string" && data.mfaTicket.trim().length > 0
+ ? data.mfaTicket
+ : undefined;
if (
- (response.status === 401 || response.status === 403 || needsMfa) &&
- typeof data?.mfaToken === "string"
+ (response.status === 401 || response.status === 403 || response.ok || needsMfa) &&
+ typeof mfaToken === "string"
) {
const result = NextResponse.json(
{ success: false, error: errorCode, needMfa: true },
{ status: 401 },
);
- applyMfaCookie(result, data.mfaToken);
+ applyMfaCookie(result, mfaToken);
clearSessionCookie(result, request.headers.get("host") ?? undefined);
return result;
}
diff --git a/src/app/api/auth/mfa/verify/route.ts b/src/app/api/auth/mfa/verify/route.ts
index a57789b..e36f595 100644
--- a/src/app/api/auth/mfa/verify/route.ts
+++ b/src/app/api/auth/mfa/verify/route.ts
@@ -23,6 +23,7 @@ type AccountVerifyResponse = {
token?: string;
expiresAt?: string;
mfaToken?: string;
+ mfaTicket?: string;
error?: string;
retryAt?: string;
user?: Record | null;
@@ -69,12 +70,12 @@ export async function POST(request: NextRequest) {
}
try {
- const response = await fetch(`${ACCOUNT_API_BASE}/mfa/totp/verify`, {
+ const response = await fetch(`${ACCOUNT_API_BASE}/mfa/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
- body: JSON.stringify({ token, code }),
+ body: JSON.stringify({ mfaToken: token, code }),
cache: "no-store",
});
@@ -110,8 +111,15 @@ export async function POST(request: NextRequest) {
{ status: response.status || 400 },
);
- if (typeof data?.mfaToken === "string" && data.mfaToken.trim()) {
- applyMfaCookie(result, data.mfaToken);
+ const nextToken =
+ typeof data?.mfaToken === "string" && data.mfaToken.trim()
+ ? data.mfaToken
+ : typeof data?.mfaTicket === "string" && data.mfaTicket.trim()
+ ? data.mfaTicket
+ : "";
+
+ if (nextToken) {
+ applyMfaCookie(result, nextToken);
} else {
applyMfaCookie(result, token);
}
From 4329953274c595bfbd8acc0a9dcc6b7677ce71eb Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 17 Mar 2026 10:22:50 +0000
Subject: [PATCH 03/10] docs: add ui refactor proposal
Add docs/ui-refactor-proposal.md to outline the UI theme and style refactoring plan, including design system tokens, typography, responsive layout strategies, semantic navigation menu refactoring, and accessibility testing guidelines as requested.
Co-authored-by: cloud-neutral <4133689+cloud-neutral@users.noreply.github.com>
---
docs/ui-refactor-proposal.md | 218 +++++++++++++++++++++++++++++++++++
1 file changed, 218 insertions(+)
create mode 100644 docs/ui-refactor-proposal.md
diff --git a/docs/ui-refactor-proposal.md b/docs/ui-refactor-proposal.md
new file mode 100644
index 0000000..60f0e30
--- /dev/null
+++ b/docs/ui-refactor-proposal.md
@@ -0,0 +1,218 @@
+# UI 主题与风格重构方案
+
+作为资深 Web UI 设计师和前端工程师,针对我们 SaaS 控制台当前的 Next.js + Tailwind CSS 架构,结合 WCAG 可访问性标准和跨端响应式需求,特制定此 UI 重构方案。方案旨在提升整体视觉一致性、深色模式的可访问性,并优化桌面与移动端的用户体验(特别是 iOS/Android 浏览器的触控和渲染差异)。
+
+---
+
+## 1. 审查现有界面
+
+在审查当前的界面代码(如 `tailwind.config.js`, `src/app/globals.css`, `src/components/theme/` 及 `Navbar.tsx`)后,我发现了以下改进点:
+
+* **色彩硬编码与对比度**:在部分文件(如 `designTokens.ts` 和 `Navbar.tsx` 的内联类名)中仍然存在类似 `#3467e9`, `bg-[#f6f7f9]` 的硬编码颜色,这破坏了主题切换的完整性。同时,深色模式下的次级文本(如 `text-muted` `#cbd5f5`)在深色背景(`#0f172a`)上的对比度可能无法满足 WCAG AA 级 4.5:1 的标准。
+* **语义化不足**:`Navbar.tsx` 中的菜单项过度使用了 `` 和普通的 `
` 标签,缺少 ``, ``, `` 结构,并且缺乏管理下拉/折叠状态的 `aria-expanded` 属性。
+* **触控目标尺寸**:移动端的某些交互元素(如链接和图标按钮)没有保证至少 44px × 44px 的物理点击区域,这在 iOS/Android 设备上容易造成误触。
+* **深色模式层级**:深色模式主要依赖背景色区分层级(如 `surface-muted`),缺乏细微的边框(Border)或发光阴影(Glow/Shadow)来凸显浮动面板(如 Dropdown 菜单)。
+
+---
+
+## 2. 定义设计系统 Token
+
+我们需要收敛硬编码颜色,改用语义化的 CSS 变量(Token),并对深/浅色模式设定严格的对比度要求。
+
+### 颜色 Token 规划
+
+* **背景色**:
+ * 浅色:纯白 `#ffffff` (Surface) 或极浅灰 `#f8fafc` (Background)。
+ * 深色:避免纯黑,使用 `#0f172a` (Background) 和微亮的暗灰 `#1e293b` (Surface),减轻视觉疲劳。
+* **文本色**:
+ * 主要文本 (`--color-text`):浅色模式 `#0f172a`,深色模式 `#f8fafc`。
+ * 次要文本 (`--color-text-muted`):确保在深浅背景下对比度均大于 4.5:1。浅色推荐 `#475569`,深色推荐 `#94a3b8`。
+* **主色与交互色**:
+ * `--color-primary`:统一使用高对比度的主题蓝(如 `#2563eb`),确保按钮上的白色文字(`--color-primary-foreground`)对比度达标。
+
+### Tailwind / CSS 变量伪代码
+
+```css
+/* src/app/globals.css 补充与覆盖 */
+:root {
+ /* 基础结构色 */
+ --bg-color: #f8fafc;
+ --surface-color: #ffffff;
+ /* 文本色 */
+ --text-color: #0f172a;
+ --secondary-color: #475569;
+ /* 主色调 */
+ --primary: #2563eb;
+ --primary-foreground: #ffffff;
+ /* 边框与分隔线 */
+ --border-color: #e2e8f0;
+}
+
+:root[data-theme="dark"],
+.dark {
+ --bg-color: #0f172a;
+ --surface-color: #1e293b;
+ --text-color: #f8fafc;
+ --secondary-color: #94a3b8; /* 保证对比度 > 4.5:1 */
+ --primary: #3b82f6; /* 在深色背景下稍微提亮主色 */
+ --primary-foreground: #ffffff;
+ --border-color: #334155;
+ /* 深色模式下的特殊视觉补偿 */
+ --shadow-elevation: 0 4px 6px -1px rgba(0, 0, 0, 0.5), 0 2px 4px -2px rgba(0, 0, 0, 0.5);
+}
+```
+
+---
+
+## 3. 设计排版体系
+
+排版需兼顾多语言(中英文混合)和多端阅读体验。
+
+* **字体选择**:保留现有的 `Geist`,同时后备配置 `Inter` 或 `Noto Sans SC` 以优化中文显示:
+ `font-family: var(--font-geist-sans), 'Inter', 'Noto Sans SC', sans-serif;`
+* **字号范围 (Fluid Typography / 断点响应)**:
+ * **移动端**:基础字号 16px(防止 iOS Safari 输入框自动缩放),正文 `16px - 18px`,大标题约 `24px - 28px`。
+ * **桌面端**:正文 `16px - 20px`,大标题可达 `32px - 48px`。
+* **行高与间距**:正文行高 `1.5` 至 `1.6`(150%-160%),段落间距使用 `margin-bottom: 1.5em`。标题行高缩紧至 `1.2`。
+
+---
+
+## 4. 构建全局样式与主题切换
+
+当前系统已通过 Zustand (`store.ts`) 和 `ThemeProvider.tsx` 实现了基于 `localStorage` 和 `data-theme` 的切换。
+
+* **优化防闪烁 (FOUC)**:由于在 Next.js 中客户端 Hydration 会有延迟,需要确保在 `` 中注入一个同步脚本读取 `localStorage` 和 `prefers-color-scheme`,在 React 渲染前就应用 `dark` 类或 `data-theme`。
+* **深色模式下的层级分离**:避免仅仅改变背景色。对于弹窗、Dropdown 等元素,在深色模式下增加一个极细的亮色边框:
+ ```css
+ .dark .surface-elevated {
+ border: 1px solid rgba(255,255,255,0.1);
+ box-shadow: var(--shadow-elevation);
+ }
+ ```
+
+---
+
+## 5. 响应式布局策略
+
+* **断点定义**:
+ * 移动端:`< 768px` (Tailwind 默认 `md` 以下)。
+ * 平板/桌面端:`>= 768px` (`md` 及以上)。
+* **移动端 (iOS/Android) 策略**:
+ * 隐藏复杂的侧边栏(Sidebar),顶部导航精简为 Logo 和汉堡菜单。
+ * 所有的按钮 (`button`, `a`) 必须拥有至少 `min-h-[44px] min-w-[44px]` 的触控区域(可以使用 padding 撑开)。
+* **桌面端策略**:
+ * 最大化利用横向空间,采用 Sidebar + Main Content 或全宽 Header 的多列布局。
+
+---
+
+## 6. 重构导航菜单 (示例)
+
+当前 `Navbar.tsx` 使用了平铺的链接。我们需要使用语义化标签,并通过 `aria-expanded` 增强可访问性,并分离移动端的汉堡菜单和桌面端菜单。
+
+### 菜单组件伪代码
+
+```tsx
+'use client'
+import { useState } from 'react';
+
+export default function SemanticNavbar() {
+ const [isMobileMenuOpen, setIsMobileMenuOpen] = useState(false);
+ const [isDropdownOpen, setIsDropdownOpen] = useState(false);
+
+ return (
+
+ );
+}
+```
+
+---
+
+## 7. 针对移动端和桌面端的优化差异
+
+* **iOS/Android 浏览器差异处理**:
+ * **iOS Safari 底部安全区**:在主内容区底部增加 `padding-bottom: env(safe-area-inset-bottom);`,避免按钮被 Home Indicator 遮挡。
+ * **字体抗锯齿 (Anti-aliasing)**:在 `globals.css` 中的 `body` 标签已设置 `-webkit-font-smoothing: antialiased;`,在深色模式下这能让细小的亮色文字边缘更加平滑,没有晕影。
+ * **长文本限制**:在博客或文档页面,容器设置 `max-w-prose` (相当于 `max-width: 65ch`),确保每行不超过 60 个字符,提升阅读体验。
+* **桌面端强化**:
+ * 可利用 Hover 状态提供丰富的反馈(如背景变色、细微的 `transform: translateY(-1px)`)。
+ * 在宽屏上显示侧边栏和次要信息列(多列网格布局)。
+
+---
+
+## 8. 可访问性测试与迭代流程
+
+为确保重构后的界面符合标准,开发团队应在提交代码前进行以下检查:
+
+1. **对比度扫描**:使用 Chrome DevTools 的 Lighthouse 或 WebAIM 对比度检查器,确保所有的正文/背景对比度 >= 4.5:1,大号文本 >= 3.0:1。
+2. **重排与缩放测试**:使用浏览器的缩放功能放大页面到 `200%` 和 `400%`。确保在 `400%` 下页面自动转为单列移动端布局,且文字不出现截断或相互重叠(开启 CSS 文本重排 `text-wrap: balance` 或避免固定高度)。
+3. **键盘导航测试**:不使用鼠标,仅用 `Tab` 和 `Enter` 遍历界面。确保所有交互元素有明显的 `:focus-visible` 外框线(Tailwind `focus-visible:ring-2 focus-visible:ring-primary`)。二级菜单不能在 Tab 聚焦到父元素时自动弹出(除非是用按钮触发),以避免用户被迫穿过无数个子菜单才能到达下一个主栏目。
+4. **屏幕阅读器体验**:开启 VoiceOver (Mac/iOS) 或 TalkBack (Android),验证 `aria-expanded`, `aria-controls` 等状态是否能被正确播报。
From 9f0f56f509d345e68a0b66081d54c9013b5608e7 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Tue, 17 Mar 2026 10:42:59 +0000
Subject: [PATCH 04/10] fix: explicit dynamic rendering for xworkmate routes
The layout.tsx applies `export const dynamic = 'error'` globally, which causes the build to fail for routes that use dynamic functions like `headers()`. This commit explicitly adds `export const dynamic = 'force-dynamic'` to the `/xworkmate/page.tsx`, `/xworkmate/admin/page.tsx`, and `/xworkmate/integrations/page.tsx` routes, resolving the Next.js static rendering build error.
Co-authored-by: cloud-neutral <4133689+cloud-neutral@users.noreply.github.com>
---
src/app/xworkmate/admin/page.tsx | 2 ++
src/app/xworkmate/integrations/page.tsx | 2 ++
src/app/xworkmate/page.tsx | 2 ++
3 files changed, 6 insertions(+)
diff --git a/src/app/xworkmate/admin/page.tsx b/src/app/xworkmate/admin/page.tsx
index dff0a3a..68f9046 100644
--- a/src/app/xworkmate/admin/page.tsx
+++ b/src/app/xworkmate/admin/page.tsx
@@ -1,6 +1,8 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
+export const dynamic = "force-dynamic";
+
import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor";
import {
buildSharedXWorkmateUrl,
diff --git a/src/app/xworkmate/integrations/page.tsx b/src/app/xworkmate/integrations/page.tsx
index ba03674..9a52775 100644
--- a/src/app/xworkmate/integrations/page.tsx
+++ b/src/app/xworkmate/integrations/page.tsx
@@ -1,6 +1,8 @@
import { headers } from "next/headers";
import { redirect } from "next/navigation";
+export const dynamic = "force-dynamic";
+
import { XWorkmateProfileEditor } from "@/components/xworkmate/XWorkmateProfileEditor";
import {
buildSharedXWorkmateUrl,
diff --git a/src/app/xworkmate/page.tsx b/src/app/xworkmate/page.tsx
index 60fbe89..5615354 100644
--- a/src/app/xworkmate/page.tsx
+++ b/src/app/xworkmate/page.tsx
@@ -2,6 +2,8 @@ import { Suspense } from "react";
import { headers } from "next/headers";
import { redirect } from "next/navigation";
+export const dynamic = "force-dynamic";
+
import { XWorkmateLoading } from "@/app/xworkmate/XWorkmateLoading";
import { XWorkmateWorkspacePage } from "@/components/xworkmate/XWorkmateWorkspacePage";
import {
From c15c57204a2b8ced2ffe85fb61f41af409d09d3f Mon Sep 17 00:00:00 2001
From: Haitao Pan
Date: Tue, 17 Mar 2026 19:42:12 +0800
Subject: [PATCH 05/10] Fix footer contrast in light theme
---
src/components/Footer.tsx | 47 +++++++++++++++++++++++++++++----------
1 file changed, 35 insertions(+), 12 deletions(-)
diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx
index 6165cfb..65ad7b4 100644
--- a/src/components/Footer.tsx
+++ b/src/components/Footer.tsx
@@ -35,17 +35,36 @@ export default function Footer() {
setView(view === "classic" ? "material" : "classic");
};
+ const footerClassName = isDark
+ ? "border-white/10 bg-[linear-gradient(135deg,rgba(15,23,42,0.95),rgba(30,41,59,0.92))] text-slate-300 shadow-[0_24px_60px_rgba(15,23,42,0.16)]"
+ : "border-slate-200/90 bg-[linear-gradient(135deg,rgba(255,255,255,0.96),rgba(244,247,252,0.95))] text-slate-600 shadow-[0_24px_60px_rgba(148,163,184,0.18)]";
+ const linkClassName = isDark
+ ? "transition-colors hover:text-white"
+ : "transition-colors hover:text-slate-950";
+ const iconButtonClassName = isDark
+ ? "border-white/10 bg-white/5 text-white hover:border-indigo-400/50 hover:text-indigo-100"
+ : "border-slate-200 bg-white text-slate-600 hover:border-slate-300 hover:text-slate-950";
+ const controlButtonClassName = isDark
+ ? "border-white/10 bg-white/5 text-white hover:border-indigo-400/50 focus-visible:outline-indigo-500"
+ : "border-slate-200 bg-white text-slate-700 hover:border-slate-300 focus-visible:outline-slate-400";
+ const themeIconClassName = isDark ? "text-slate-300" : "text-slate-500";
+ const moonClassName = isDark ? "text-white" : "text-slate-500";
+ const sunClassName = isDark ? "text-slate-500" : "text-amber-500";
+ const thumbClassName = isDark ? "bg-white" : "bg-slate-900";
+
return (
-
-
-
-
-
- {isChinese ? "主要入口" : "Launch paths"}
-
-
- {isChinese
- ? "从接入、托管到观测,保留原有入口,但改成更轻的阅读节奏。"
- : "Keep the same entry points, but present them with a calmer editorial rhythm."}
-
-
-
- {t.heroCards.length} {isChinese ? "个入口" : "entry paths"}
-
-
-
-
- {t.heroCards.map((card) => {
- const Icon = getIcon(card.title, PlusCircle);
- return (
-
- );
- })}
-
+
+ card.title)}
+ isChinese={isChinese}
+ />
@@ -498,6 +471,113 @@ type LatestBlogPost = {
date?: string;
};
+function HeroVideoShell({
+ items,
+ isChinese,
+}: {
+ items: string[];
+ isChinese: boolean;
+}) {
+ return (
+
+
+
+
+
+ {isChinese ? "产品演示" : "Product demo"}
+
+
+ {isChinese
+ ? "这里预留为视频展示区,后续可以直接替换成产品介绍、工作流演示或 onboarding 视频。"
+ : "Reserved for a video showcase. You can later replace it with a product intro, workflow demo, or onboarding clip."}
+
+
+
+ {isChinese ? "16:9 占位" : "16:9 shell"}
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {isChinese ? "视频待接入" : "Video pending"}
+
+
+ 00:00 / 02:18
+
+
+
+
+
+
+
+
+
+ {isChinese
+ ? "用一段视频解释从灵感到上线的完整路径"
+ : "Show the full path from idea to launch in one video"}
+
+
+ {isChinese
+ ? "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。"
+ : "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough."}
+
+
+
+
+
+
+
+
+ {isChinese ? "开场介绍" : "Intro"}
+
+
+ {isChinese ? "集成配置" : "Setup"}
+
+
+ {isChinese ? "上线演示" : "Launch"}
+
+
+
+
+
+
+
+ {items.map((item) => (
+
+ {item}
+
+ ))}
+
+
+
+ );
+}
+
function LogoPill({ label }: { label: string }) {
return (
From d6d062daa91d962c584968bfd76f6c436dd9ec68 Mon Sep 17 00:00:00 2001
From: Haitao Pan
Date: Tue, 17 Mar 2026 20:02:45 +0800
Subject: [PATCH 07/10] refactor(public-pages): unify docs services and about
styling
---
src/app/about/page.tsx | 298 ++++++++++---------
src/app/docs/Feedback.tsx | 68 +++--
src/app/docs/[collection]/[...slug]/page.tsx | 132 ++++----
src/app/docs/page.tsx | 173 +++++++++--
src/app/globals.css | 150 ++++++++++
src/app/services/page.tsx | 292 +++++++-----------
src/components/doc/DocArticle.tsx | 10 +-
src/components/doc/DocMetaPanel.tsx | 39 ++-
src/components/public/PublicPageShell.tsx | 82 +++++
9 files changed, 779 insertions(+), 465 deletions(-)
create mode 100644 src/components/public/PublicPageShell.tsx
diff --git a/src/app/about/page.tsx b/src/app/about/page.tsx
index 44acc0c..f8156bb 100644
--- a/src/app/about/page.tsx
+++ b/src/app/about/page.tsx
@@ -1,161 +1,167 @@
"use client";
-import React from "react";
-import { translations } from "../../i18n/translations";
-import { useLanguage } from "../../i18n/LanguageProvider";
-import UnifiedNavigation from "../../components/UnifiedNavigation";
-import Footer from "../../components/Footer";
+import { AlertTriangle, ArrowUpRight, Heart, Sparkles } from "lucide-react";
+
+import {
+ PublicPageIntro,
+ PublicPageShell,
+} from "@/components/public/PublicPageShell";
+import { useLanguage } from "@/i18n/LanguageProvider";
+import { translations } from "@/i18n/translations";
+import { cn } from "@/lib/utils";
export default function AboutPage() {
const { language } = useLanguage();
+ const isChinese = language === "zh";
const t = translations[language].about;
return (
-
-
+
+
+
+
-
-
-
-
-
- {/* Header */}
-
-
- {t.title}
-
-
{t.subtitle}
-
-
- {/* Disclaimer Section */}
-
-
-
-
-
- Disclaimer
-
-
- {t.disclaimer}
-
-
-
-
-
- {/* Acknowledgments */}
-
-
-
- {t.acknowledgmentsTitle}
-
-
- {t.acknowledgments}
-
-
-
- {t.sections.map((section, sIndex) => (
-
-
- {section.title}
-
-
- {section.content && (
-
- {section.content}
-
- )}
-
- {section.items && (
-
- {section.items.map((item, iIndex) => (
-
- ))}
-
- )}
-
- {section.links && (
-
- )}
-
- ))}
-
-
-
-
-
-
+
+
+ {isChinese ? "维护方式" : "Maintenance"}
+
+
+
+
+
+ {isChinese
+ ? "独立开发者维护,围绕 AI 服务、可观测性与云原生控制面持续演进。"
+ : "Maintained independently, evolving around AI services, observability, and cloud-native control planes."}
+
-
+
+
-
-
-
+
+
+
+
+
+ {isChinese ? "免责声明" : "Disclaimer"}
+
+
+ {t.disclaimer}
+
+
+
+
+
+
+
+
+
+ {isChinese ? "致谢与驱动力" : "Acknowledgements"}
+
+
+ {t.acknowledgmentsTitle}
+
+
+ {t.acknowledgments}
+
+
+
+
+ {t.sections.map((section, index) => (
+
+
+
+ {isChinese ? "章节" : "Section"} {index + 1}
+
+
+ {section.title}
+
+
+
+ {section.content ? (
+
+ {section.content}
+
+ ) : null}
+
+ {section.items ? (
+
+ ) : null}
+
+ {section.links ? (
+
+ ) : null}
+
+ ))}
+
+
+
+
+
+
+
+
+
+
+
+ {isChinese ? "开源协作" : "Open source"}
+
+
+ {t.opensource}
+
+
+
+
+
);
}
diff --git a/src/app/docs/Feedback.tsx b/src/app/docs/Feedback.tsx
index 61b2514..bab4f0d 100644
--- a/src/app/docs/Feedback.tsx
+++ b/src/app/docs/Feedback.tsx
@@ -1,36 +1,44 @@
-'use client'
+"use client";
-import { useState } from 'react'
-import { ThumbsUp, ThumbsDown } from 'lucide-react'
+import { useState } from "react";
+import { ThumbsDown, ThumbsUp } from "lucide-react";
export default function Feedback() {
- const [voted, setVoted] = useState<'yes' | 'no' | null>(null)
+ const [voted, setVoted] = useState<"yes" | "no" | null>(null);
- return (
-
-
-
Is this page helpful?
- {voted === null ? (
-
- setVoted('yes')}
- className="flex items-center gap-2 rounded-md border border-surface-border bg-surface px-4 py-2 text-sm font-medium text-text transition hover:border-primary hover:text-primary"
- >
-
- Yes
-
- setVoted('no')}
- className="flex items-center gap-2 rounded-md border border-surface-border bg-surface px-4 py-2 text-sm font-medium text-text transition hover:border-danger hover:text-danger"
- >
-
- No
-
-
- ) : (
-
Thanks for your feedback!
- )}
-
+ return (
+
+
+
+
+ Feedback
+
+
+ Is this page helpful?
+
- )
+
+ {voted === null ? (
+
+ setVoted("yes")}
+ className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm font-semibold text-slate-800 transition hover:border-primary/20 hover:text-primary"
+ >
+
+ Yes
+
+ setVoted("no")}
+ className="inline-flex items-center gap-2 rounded-full border border-slate-900/10 bg-white px-4 py-2 text-sm font-semibold text-slate-800 transition hover:border-danger/20 hover:text-danger"
+ >
+
+ No
+
+
+ ) : (
+
Thanks for your feedback.
+ )}
+
+
+ );
}
diff --git a/src/app/docs/[collection]/[...slug]/page.tsx b/src/app/docs/[collection]/[...slug]/page.tsx
index 0b2ecbd..8c50355 100644
--- a/src/app/docs/[collection]/[...slug]/page.tsx
+++ b/src/app/docs/[collection]/[...slug]/page.tsx
@@ -1,104 +1,128 @@
-export const dynamic = 'error'
-export const revalidate = false
+export const dynamic = "error";
+export const revalidate = false;
-import { notFound } from 'next/navigation'
-import type { Metadata } from 'next'
+import type { Metadata } from "next";
+import Link from "next/link";
+import { notFound } from "next/navigation";
+import { ChevronRight } from "lucide-react";
-import DocArticle from '@/components/doc/DocArticle'
-import DocMetaPanel from '@/components/doc/DocMetaPanel'
-import Feedback from '../../Feedback'
-import { getDocVersionParams, getDocVersion } from '../../resources.server'
-import { isFeatureEnabled } from '@lib/featureToggles'
-import Link from 'next/link'
-import { ChevronRight } from 'lucide-react'
+import DocArticle from "@/components/doc/DocArticle";
+import DocMetaPanel from "@/components/doc/DocMetaPanel";
+import { PublicPageIntro } from "@/components/public/PublicPageShell";
+import { isFeatureEnabled } from "@lib/featureToggles";
-// Simple Breadcrumbs Component inline (or could be separate)
-function DocsBreadcrumbs({ items }: { items: { label: string; href: string }[] }) {
+import Feedback from "../../Feedback";
+import { getDocVersion, getDocVersionParams } from "../../resources.server";
+
+function DocsBreadcrumbs({
+ items,
+}: {
+ items: { label: string; href: string }[];
+}) {
return (
-
+
{items.map((item, index) => (
- {index > 0 && }
+ {index > 0 ? (
+
+ ) : null}
{item.label}
))}
- )
+ );
}
export const generateStaticParams = async () => {
- if (!isFeatureEnabled('appModules', '/docs')) {
- return []
+ if (!isFeatureEnabled("appModules", "/docs")) {
+ return [];
}
- return getDocVersionParams()
-}
+ return getDocVersionParams();
+};
-export const dynamicParams = false
+export const dynamicParams = false;
+
+export async function generateMetadata({
+ params,
+}: {
+ params: Promise<{ collection: string; slug: string[] }>;
+}): Promise {
+ const resolvedParams = await params;
+ const doc = await getDocVersion(
+ resolvedParams.collection,
+ resolvedParams.slug,
+ );
+ if (!doc) return {};
-export async function generateMetadata({ params }: { params: Promise<{ collection: string; slug: string[] }> }): Promise {
- const resolvedParams = await params
- const doc = await getDocVersion(resolvedParams.collection, resolvedParams.slug)
- if (!doc) return {}
return {
title: `${doc.version.title} - ${doc.collection.title} | Documentation`,
description: doc.version.description,
- }
+ };
}
export default async function DocVersionPage({
params,
}: {
- params: Promise<{ collection: string; slug: string[] }>
+ params: Promise<{ collection: string; slug: string[] }>;
}) {
- if (!isFeatureEnabled('appModules', '/docs')) {
- notFound()
+ if (!isFeatureEnabled("appModules", "/docs")) {
+ notFound();
}
- const resolvedParams = await params
- const doc = await getDocVersion(resolvedParams.collection, resolvedParams.slug)
+ const resolvedParams = await params;
+ const doc = await getDocVersion(
+ resolvedParams.collection,
+ resolvedParams.slug,
+ );
if (!doc) {
- notFound()
+ notFound();
}
- const { collection, version } = doc
-
+ const { collection, version } = doc;
const breadcrumbs = [
- { label: 'Documentation', href: '/docs' },
+ { label: "Documentation", href: "/docs" },
{ label: collection.title, href: `/docs/${collection.slug}` },
{ label: version.title, href: `/docs/${collection.slug}/${version.slug}` },
- ]
+ ];
return (
-
- {/* Center Content */}
-
-
+
+
+
-
- {version.title}
- {version.description && {version.description}
}
-
-
-
+
+
- {/* Right Sidebar */}
-
-
-
Metadata
+
+
+
+ Metadata
+
@@ -106,5 +130,5 @@ export default async function DocVersionPage({
- )
+ );
}
diff --git a/src/app/docs/page.tsx b/src/app/docs/page.tsx
index ed88ed1..9d4d719 100644
--- a/src/app/docs/page.tsx
+++ b/src/app/docs/page.tsx
@@ -1,45 +1,154 @@
-import { notFound } from 'next/navigation'
-import { promises as fs } from 'fs'
-import path from 'path'
-import matter from 'gray-matter'
-import { marked } from 'marked'
+import { promises as fs } from "fs";
+import path from "path";
+
+import matter from "gray-matter";
+import { ArrowRight, BookCopy, Files } from "lucide-react";
+import Link from "next/link";
+
+import DocArticle from "@/components/doc/DocArticle";
+import { PublicPageIntro } from "@/components/public/PublicPageShell";
+
+import { getDocCollections } from "./resources.server";
export default async function DocsHome() {
try {
- // Read the index.md file
- const indexPath = path.join(process.cwd(), 'src', 'content', 'doc', 'index.md')
- const fileContent = await fs.readFile(indexPath, 'utf-8')
- const { data: frontmatter, content } = matter(fileContent)
-
- // Convert markdown to HTML
- const htmlContent = await marked(content)
+ const indexPath = path.join(
+ process.cwd(),
+ "src",
+ "content",
+ "doc",
+ "index.md",
+ );
+ const [fileContent, collections] = await Promise.all([
+ fs.readFile(indexPath, "utf-8"),
+ getDocCollections(),
+ ]);
+ const { data: frontmatter, content } = matter(fileContent);
+ const articleCount = collections.reduce(
+ (sum, collection) => sum + collection.versions.length,
+ 0,
+ );
return (
-
-
+
+
+
+
-
+
+
+ Library snapshot
+
+
+
+
+
+ Collections
+
+
+ {collections.length}
+
+
+
+
+
+ Articles
+
+
+ {articleCount}
+
+
+
+
+
+
+
+ {collections.length > 0 ? (
+
+
+
+
+ Browse collections
+
+
+ Documentation sections now use the same card language as the
+ rest of the public site.
+
+
+
+ {collections.length} collections
+
+
+
+
+ {collections.map((collection) => (
+
+
+
+
+ {collection.title}
+
+
+ {collection.description}
+
+
+
+
+
+
+
+ {collection.versions.length} articles
+
+ {collection.tags.slice(0, 2).map((tag) => (
+
+ {tag}
+
+ ))}
+
+
+ ))}
+
+
+ ) : null}
+
+
- )
+ );
} catch (error) {
- console.error('Failed to load docs index:', error)
+ console.error("Failed to load docs index:", error);
+
return (
-
-
No Documentation Found
-
- We could not find any documentation files. Please ensure content is synced to src/content/doc.
+
+
+ No Documentation Found
+
+
+ We could not find any documentation files. Please ensure content is
+ synced to src/content/doc.
- )
+ );
}
}
diff --git a/src/app/globals.css b/src/app/globals.css
index 795bcfc..db7e724 100644
--- a/src/app/globals.css
+++ b/src/app/globals.css
@@ -161,6 +161,156 @@ button {
font-family: var(--font-editorial-display);
letter-spacing: -0.04em;
}
+
+ .public-doc-prose {
+ max-width: none;
+ color: var(--color-text-muted);
+ font-size: 1rem;
+ line-height: 1.85;
+ }
+
+ .public-doc-prose > :first-child {
+ margin-top: 0;
+ }
+
+ .public-doc-prose > :last-child {
+ margin-bottom: 0;
+ }
+
+ .public-doc-prose h1,
+ .public-doc-prose h2,
+ .public-doc-prose h3,
+ .public-doc-prose h4,
+ .public-doc-prose h5,
+ .public-doc-prose h6 {
+ scroll-margin-top: calc(var(--app-shell-nav-offset) + 1rem);
+ margin-top: 2.75rem;
+ margin-bottom: 0.9rem;
+ color: var(--color-heading);
+ font-weight: 600;
+ letter-spacing: -0.04em;
+ line-height: 1.08;
+ }
+
+ .public-doc-prose h1 {
+ font-size: clamp(2rem, 4vw, 2.9rem);
+ }
+
+ .public-doc-prose h2 {
+ font-size: clamp(1.55rem, 2.2vw, 2rem);
+ }
+
+ .public-doc-prose h3 {
+ font-size: clamp(1.25rem, 1.8vw, 1.5rem);
+ }
+
+ .public-doc-prose p,
+ .public-doc-prose ul,
+ .public-doc-prose ol,
+ .public-doc-prose blockquote,
+ .public-doc-prose pre,
+ .public-doc-prose table {
+ margin: 1rem 0;
+ }
+
+ .public-doc-prose p,
+ .public-doc-prose li {
+ color: var(--color-text-muted);
+ line-height: 1.85;
+ }
+
+ .public-doc-prose strong {
+ color: var(--color-heading);
+ font-weight: 600;
+ }
+
+ .public-doc-prose a {
+ color: var(--color-primary);
+ font-weight: 600;
+ text-decoration: none;
+ }
+
+ .public-doc-prose a:hover {
+ text-decoration: underline;
+ }
+
+ .public-doc-prose ul,
+ .public-doc-prose ol {
+ padding-left: 1.35rem;
+ }
+
+ .public-doc-prose li + li {
+ margin-top: 0.35rem;
+ }
+
+ .public-doc-prose code {
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 999px;
+ background: #f8f4ec;
+ padding: 0.12rem 0.42rem;
+ color: var(--color-heading);
+ font-family: var(--font-geist-mono);
+ font-size: 0.92em;
+ }
+
+ .public-doc-prose pre {
+ overflow-x: auto;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 1.25rem;
+ background: #f5f2eb;
+ padding: 1rem 1.15rem;
+ box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.5);
+ }
+
+ .public-doc-prose pre code {
+ border: none;
+ border-radius: 0;
+ background: transparent;
+ padding: 0;
+ color: inherit;
+ font-size: 0.92rem;
+ }
+
+ .public-doc-prose blockquote {
+ border-left: 2px solid rgba(51, 102, 255, 0.24);
+ border-radius: 0 1rem 1rem 0;
+ background: rgba(248, 244, 236, 0.78);
+ padding: 0.75rem 0 0.75rem 1rem;
+ color: var(--color-heading);
+ }
+
+ .public-doc-prose hr {
+ margin: 2rem 0;
+ border: none;
+ border-top: 1px solid var(--color-divider);
+ }
+
+ .public-doc-prose table {
+ width: 100%;
+ border-collapse: collapse;
+ overflow: hidden;
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 1rem;
+ }
+
+ .public-doc-prose th,
+ .public-doc-prose td {
+ border-bottom: 1px solid rgba(15, 23, 42, 0.08);
+ padding: 0.8rem 1rem;
+ text-align: left;
+ vertical-align: top;
+ }
+
+ .public-doc-prose th {
+ background: rgba(248, 244, 236, 0.82);
+ color: var(--color-heading);
+ font-weight: 600;
+ }
+
+ .public-doc-prose img {
+ border: 1px solid rgba(15, 23, 42, 0.08);
+ border-radius: 1rem;
+ }
}
@media (max-width: 1023px) {
diff --git a/src/app/services/page.tsx b/src/app/services/page.tsx
index d884dc0..2bbc566 100644
--- a/src/app/services/page.tsx
+++ b/src/app/services/page.tsx
@@ -7,77 +7,56 @@ import {
Bot,
Box,
CloudCog,
+ Command,
Database,
FileEdit,
Gauge,
+ type LucideIcon,
MessageCircle,
Network,
} from "lucide-react";
-import Footer from "../../components/Footer";
-import UnifiedNavigation from "../../components/UnifiedNavigation";
-import { useLanguage } from "../../i18n/LanguageProvider";
-import { useViewStore } from "../../components/theme/viewStore";
-import Material3Layout from "./Material3Layout";
+
+import {
+ PublicPageIntro,
+ PublicPageShell,
+} from "@/components/public/PublicPageShell";
+import { useLanguage } from "@/i18n/LanguageProvider";
+import { cn } from "@/lib/utils";
const placeholderCount = 1;
+
type ServiceCardData = {
key: string;
name: string;
description: string;
href: string;
- icon: any;
+ icon: LucideIcon;
external?: boolean;
};
-const ServiceCard = ({
+function ServiceCard({
service,
- view,
isChinese,
}: {
service: ServiceCardData;
- view: "classic" | "material";
isChinese: boolean;
-}) => {
- const isMaterial = view === "material";
-
- const cardContent = (
-
-
-
+}) {
+ const content = (
+
+
+
-
-
+
+
{service.name}
-
-
+
+
{service.description}
-
+
{isChinese ? "打开" : "Open"}
@@ -92,111 +71,44 @@ const ServiceCard = ({
rel="noopener noreferrer"
className="block"
>
- {cardContent}
+ {content}
);
}
return (
- {cardContent}
+ {content}
);
-};
-
-const PlaceholderCard = ({
- view,
- isChinese,
-}: {
- view: "classic" | "material";
- isChinese: boolean;
-}) => {
- const isMaterial = view === "material";
- const placeholderLabel = isChinese
- ? "更多服务即将上线"
- : "More services coming soon";
- const placeholderDescription = isChinese
- ? "预留卡片位置,持续扩充入口。"
- : "Reserved slots for new service entries.";
+}
+function PlaceholderCard({ isChinese }: { isChinese: boolean }) {
return (
-
-
-
+
+
+
-
- {placeholderLabel}
+
+
+ {isChinese ? "更多服务即将上线" : "More services coming soon"}
+
+
+ {isChinese
+ ? "预留卡片位置,持续扩充入口。"
+ : "Reserved slots for new service entries."}
+
-
- {placeholderDescription}
-
-
+
{isChinese ? "敬请期待" : "Stay tuned"}
);
-};
-
-const ServiceGrid = ({
- view,
- services,
- isChinese,
-}: {
- view: "classic" | "material";
- services: ServiceCardData[];
- isChinese: boolean;
-}) => {
- return (
-
- {services.map((service) => (
-
- ))}
- {Array.from({ length: placeholderCount }).map((_, index) => (
-
- ))}
-
- );
-};
-
-const ClawdbotLogo = (props: any) => (
-
-);
+}
export default function ServicesPage() {
- const { view, isHydrated } = useViewStore();
const { language } = useLanguage();
const isChinese = language === "zh";
@@ -299,71 +211,83 @@ export default function ServicesPage() {
external: true,
},
{
- key: "moltbot",
+ key: "xworkmate",
name: "XWorkmate",
description: isChinese
? "在线版 XWorkmate 工作区,底层由 OpenClaw gateway 驱动。"
: "Online XWorkmate workspace powered by the OpenClaw gateway.",
href: "/xworkmate",
- icon: ClawdbotLogo,
+ icon: Command,
},
];
- if (!isHydrated) {
- return null;
- }
-
- if (view === "material") {
- return (
-
-
-
- Service Overview
-
-
- Real-time metrics and system health for your current production
- environment.
-
-
-
-
- );
- }
-
return (
-
-
-
-
-
-
-
- {isChinese ? "更多服务" : "More services"}
-
-
- {isChinese ? "扩展服务与工具箱" : "Extended Services & Toolbox"}
-
-
- {isChinese
- ? "汇聚开发辅助、运维监控与核心制品,构建无缝衔接的云原生工作台。"
- : "A unified hub for development aids, operations monitoring, and core artifacts."}
-
-
-
+
+
+
+
+ {isChinese ? "页面原则" : "Page rhythm"}
+
+
+ {isChinese
+ ? "保持结构不变,但去掉 classic / material 的风格分裂。"
+ : "Keep the structure, remove the classic/material visual split."}
+
+
+
+
+
+
+
+
+
+ {isChinese ? "服务目录" : "Service directory"}
+
+
+ {isChinese
+ ? "每个入口都使用同一种卡片语法:白底、细边框、轻阴影、明确标题。"
+ : "Every entry now follows the same card grammar: pale surface, fine border, light shadow, and clear hierarchy."}
+
+
+
+ {services.length} {isChinese ? "个入口" : "entries"}
+
+
+
+
+ {services.map((service) => (
+
+ ))}
+ {Array.from({ length: placeholderCount }).map((_, index) => (
+
+ ))}
+
+
+
);
}
diff --git a/src/components/doc/DocArticle.tsx b/src/components/doc/DocArticle.tsx
index fc0af0d..fb7e9b1 100644
--- a/src/components/doc/DocArticle.tsx
+++ b/src/components/doc/DocArticle.tsx
@@ -1,17 +1,17 @@
-import { marked } from 'marked'
+import { marked } from "marked";
interface DocArticleProps {
- content: string
+ content: string;
}
export default async function DocArticle({ content }: DocArticleProps) {
// Convert markdown to HTML
- const htmlContent = await marked(content)
+ const htmlContent = await marked(content);
return (
- )
+ );
}
diff --git a/src/components/doc/DocMetaPanel.tsx b/src/components/doc/DocMetaPanel.tsx
index bec7fb4..41407a4 100644
--- a/src/components/doc/DocMetaPanel.tsx
+++ b/src/components/doc/DocMetaPanel.tsx
@@ -1,29 +1,40 @@
-import ClientTime from '@/app/components/ClientTime'
+import ClientTime from "@/app/components/ClientTime";
interface DocMetaPanelProps {
- description?: string
- updatedAt?: string
- tags?: string[]
+ description?: string;
+ updatedAt?: string;
+ tags?: string[];
}
-export default function DocMetaPanel({ description, updatedAt, tags }: DocMetaPanelProps) {
+export default function DocMetaPanel({
+ description,
+ updatedAt,
+ tags,
+}: DocMetaPanelProps) {
return (
-
- {description &&
{description}
}
- {tags && tags.length > 0 && (
+
+ {description ? (
+
{description}
+ ) : null}
+
+ {tags && tags.length > 0 ? (
{tags.map((tag) => (
-
+
{tag}
))}
- )}
- {updatedAt && (
-
+ ) : null}
+
+ {updatedAt ? (
+
Updated
- )}
+ ) : null}
- )
+ );
}
diff --git a/src/components/public/PublicPageShell.tsx b/src/components/public/PublicPageShell.tsx
new file mode 100644
index 0000000..b4b360a
--- /dev/null
+++ b/src/components/public/PublicPageShell.tsx
@@ -0,0 +1,82 @@
+import Footer from "@/components/Footer";
+import UnifiedNavigation from "@/components/UnifiedNavigation";
+import { cn } from "@/lib/utils";
+
+type PublicPageShellProps = {
+ children: React.ReactNode;
+ mainClassName?: string;
+ containerClassName?: string;
+};
+
+type PublicPageIntroProps = {
+ eyebrow?: string;
+ title: string;
+ subtitle?: string;
+ titleClassName?: string;
+ className?: string;
+};
+
+export function PublicPageShell({
+ children,
+ mainClassName,
+ containerClassName,
+}: PublicPageShellProps) {
+ return (
+
+
+
+
+
+
+ {children}
+
+
+
+
+
+ );
+}
+
+export function PublicPageIntro({
+ eyebrow,
+ title,
+ subtitle,
+ titleClassName,
+ className,
+}: PublicPageIntroProps) {
+ return (
+
+ {eyebrow ? (
+
+ {eyebrow}
+
+ ) : null}
+
+ {title}
+
+ {subtitle ? (
+
+ {subtitle}
+
+ ) : null}
+
+ );
+}
From 3af115cf5b1c3d613426c16bdbde6929662faae6 Mon Sep 17 00:00:00 2001
From: Haitao Pan
Date: Tue, 17 Mar 2026 20:03:27 +0800
Subject: [PATCH 08/10] Add configurable media data for hero video shell
---
src/app/page.tsx | 155 ++++++++++++++++++++++++++++++++++++-----------
1 file changed, 119 insertions(+), 36 deletions(-)
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 6f0de82..2ef19ad 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -67,6 +67,51 @@ const iconMap: Record = {
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback;
+type HeroVideoMedia = {
+ posterUrl?: string;
+ videoUrl?: string;
+ title: {
+ zh: string;
+ en: string;
+ };
+ description: {
+ zh: string;
+ en: string;
+ };
+ statusLabel: {
+ zh: string;
+ en: string;
+ };
+ durationLabel: string;
+ chapters: {
+ zh: string;
+ en: string;
+ }[];
+};
+
+const heroVideoMedia: HeroVideoMedia = {
+ posterUrl: "",
+ videoUrl: "",
+ title: {
+ zh: "用一段视频解释从灵感到上线的完整路径",
+ en: "Show the full path from idea to launch in one video",
+ },
+ description: {
+ zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。",
+ en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.",
+ },
+ statusLabel: {
+ zh: "视频待接入",
+ en: "Video pending",
+ },
+ durationLabel: "00:00 / 02:18",
+ chapters: [
+ { zh: "开场介绍", en: "Intro" },
+ { zh: "集成配置", en: "Setup" },
+ { zh: "上线演示", en: "Launch" },
+ ],
+};
+
export default function HomePage() {
const { mode, isOpen } = useMoltbotStore();
@@ -185,6 +230,7 @@ export function HeroSection() {
card.title)}
isChinese={isChinese}
+ media={heroVideoMedia}
/>
@@ -474,10 +520,28 @@ type LatestBlogPost = {
function HeroVideoShell({
items,
isChinese,
+ media,
}: {
items: string[];
isChinese: boolean;
+ media: HeroVideoMedia;
}) {
+ const mediaTitle = isChinese ? media.title.zh : media.title.en;
+ const mediaDescription = isChinese
+ ? media.description.zh
+ : media.description.en;
+ const mediaStatusLabel = isChinese
+ ? media.statusLabel.zh
+ : media.statusLabel.en;
+ const hasVideo = Boolean(media.videoUrl);
+ const previewStyle = media.posterUrl
+ ? {
+ backgroundImage: `linear-gradient(180deg,rgba(15,23,42,0.18),rgba(15,23,42,0.52)), url(${media.posterUrl})`,
+ backgroundSize: "cover",
+ backgroundPosition: "center",
+ }
+ : undefined;
+
return (
@@ -499,47 +563,67 @@ function HeroVideoShell({
-
-
-
-
+
+ {hasVideo ? (
+
+
+
+ ) : (
+ <>
+
+
+
+ >
+ )}
+
- {isChinese ? "视频待接入" : "Video pending"}
+ {mediaStatusLabel}
- 00:00 / 02:18
+ {media.durationLabel}
-
-
-
+ {!hasVideo ? (
+
+
+
+ ) : null}
- {isChinese
- ? "用一段视频解释从灵感到上线的完整路径"
- : "Show the full path from idea to launch in one video"}
+ {mediaTitle}
- {isChinese
- ? "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。"
- : "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough."}
+ {mediaDescription}
@@ -549,15 +633,14 @@ function HeroVideoShell({
-
- {isChinese ? "开场介绍" : "Intro"}
-
-
- {isChinese ? "集成配置" : "Setup"}
-
-
- {isChinese ? "上线演示" : "Launch"}
-
+ {media.chapters.map((chapter) => (
+
+ {isChinese ? chapter.zh : chapter.en}
+
+ ))}
From 70004b0d0f638291fb964d7ec4d0b6b7e02be25d Mon Sep 17 00:00:00 2001
From: Haitao Pan
Date: Tue, 17 Mar 2026 20:10:23 +0800
Subject: [PATCH 09/10] Extract hero video media config
---
src/app/page.tsx | 49 +++-------------------------------
src/lib/home/heroVideoMedia.ts | 44 ++++++++++++++++++++++++++++++
2 files changed, 48 insertions(+), 45 deletions(-)
create mode 100644 src/lib/home/heroVideoMedia.ts
diff --git a/src/app/page.tsx b/src/app/page.tsx
index 2ef19ad..b8a503c 100644
--- a/src/app/page.tsx
+++ b/src/app/page.tsx
@@ -24,6 +24,10 @@ import Footer from "../components/Footer";
import UnifiedNavigation from "../components/UnifiedNavigation";
import { useLanguage } from "../i18n/LanguageProvider";
import { translations } from "../i18n/translations";
+import {
+ heroVideoMedia,
+ type HeroVideoMedia,
+} from "../lib/home/heroVideoMedia";
import { useMoltbotStore } from "../lib/moltbotStore";
import { useUserStore } from "../lib/userStore";
import { cn } from "../lib/utils";
@@ -67,51 +71,6 @@ const iconMap: Record = {
const getIcon = (key: string, fallback: any) => iconMap[key] || fallback;
-type HeroVideoMedia = {
- posterUrl?: string;
- videoUrl?: string;
- title: {
- zh: string;
- en: string;
- };
- description: {
- zh: string;
- en: string;
- };
- statusLabel: {
- zh: string;
- en: string;
- };
- durationLabel: string;
- chapters: {
- zh: string;
- en: string;
- }[];
-};
-
-const heroVideoMedia: HeroVideoMedia = {
- posterUrl: "",
- videoUrl: "",
- title: {
- zh: "用一段视频解释从灵感到上线的完整路径",
- en: "Show the full path from idea to launch in one video",
- },
- description: {
- zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。",
- en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.",
- },
- statusLabel: {
- zh: "视频待接入",
- en: "Video pending",
- },
- durationLabel: "00:00 / 02:18",
- chapters: [
- { zh: "开场介绍", en: "Intro" },
- { zh: "集成配置", en: "Setup" },
- { zh: "上线演示", en: "Launch" },
- ],
-};
-
export default function HomePage() {
const { mode, isOpen } = useMoltbotStore();
diff --git a/src/lib/home/heroVideoMedia.ts b/src/lib/home/heroVideoMedia.ts
new file mode 100644
index 0000000..1f34c30
--- /dev/null
+++ b/src/lib/home/heroVideoMedia.ts
@@ -0,0 +1,44 @@
+export type HeroVideoMedia = {
+ posterUrl?: string;
+ videoUrl?: string;
+ title: {
+ zh: string;
+ en: string;
+ };
+ description: {
+ zh: string;
+ en: string;
+ };
+ statusLabel: {
+ zh: string;
+ en: string;
+ };
+ durationLabel: string;
+ chapters: {
+ zh: string;
+ en: string;
+ }[];
+};
+
+export const heroVideoMedia: HeroVideoMedia = {
+ posterUrl: "",
+ videoUrl: "",
+ title: {
+ zh: "用一段视频解释从灵感到上线的完整路径",
+ en: "Show the full path from idea to launch in one video",
+ },
+ description: {
+ zh: "建议后续放 60 到 120 秒的产品导览、集成配置流程,或真实部署 walkthrough。",
+ en: "Best used for a 60-120 second product tour, integration setup flow, or real deployment walkthrough.",
+ },
+ statusLabel: {
+ zh: "视频待接入",
+ en: "Video pending",
+ },
+ durationLabel: "00:00 / 02:18",
+ chapters: [
+ { zh: "开场介绍", en: "Intro" },
+ { zh: "集成配置", en: "Setup" },
+ { zh: "上线演示", en: "Launch" },
+ ],
+};
From 9d2fcd635c040845cdff9f54d16a64663a5ab762 Mon Sep 17 00:00:00 2001
From: "google-labs-jules[bot]"
<161369871+google-labs-jules[bot]@users.noreply.github.com>
Date: Wed, 18 Mar 2026 04:08:22 +0000
Subject: [PATCH 10/10] feat(xworkmate): redesign console to a minimalist
layout with chat input at bottom
- Removed rounded corners and excess padding for a compact, simple feel.
- Added a collapsible sidebar to preserve space while keeping existing icons.
- Re-architected XWorkmateWorkspacePage layout to put chat/action bar at the bottom with a flex-grow central space.
- Added suggested chips (Slides, Video Gen, Deep Research, etc.) for quick tasks.
- Abstracted `pickCopy` to use generics to fix type errors.
- Added Next.js `force-dynamic` explicit rule to `/xworkmate` to allow `headers()` resolution statically conflicting with `dynamic = 'error'` in root layout.
Co-authored-by: cloud-neutral <4133689+cloud-neutral@users.noreply.github.com>
---
next_output.log | Bin 0 -> 1876 bytes
src/app/xworkmate/layout.tsx | 9 +
.../xworkmate/XWorkmateWorkspacePage.tsx | 330 ++++++++++--------
update_layout.patch | 114 ++++++
4 files changed, 299 insertions(+), 154 deletions(-)
create mode 100644 next_output.log
create mode 100644 src/app/xworkmate/layout.tsx
create mode 100644 update_layout.patch
diff --git a/next_output.log b/next_output.log
new file mode 100644
index 0000000000000000000000000000000000000000..1fffe55c59fff55ecf3566bd5038d528d3bcebe4
GIT binary patch
literal 1876
zcmeHG!EVz)5asM&;4%t_*iyZAoF+|NK`9Wmm8!IAgQ!$Nvb8tKw)U>sU6TaLS0HiX
z1GpjaN%<0H6E~s=i8E67vi8iInfIQ@%dbD7&*p|)YiP7dgS5dNl>^xdT?PMmHjjc87!{S_7Tsw=ZyRs4`qk2qCNY
zGe*+|Z~+l%YUfgw!tk8=i49C$F64_aD$xN~##nWN8=#b2~ey
z_&DQc3Zrx(XwDOO3N>bdwGkw;%u-P|aK%6|kurrnwV{Til5BzDgqE5CH-J|Pqbagt
zO1s{Oz&T@(ONCi6mN3QI7_3B%RtR+OS`(=zK`Ij+(4q*gN}i>t&|k<>`54J?is4cO
z>(Q7B*0X&oQoo}6q`LhL$g#Fl0~c>9eW!H7z||^QjoHW!3XBt@=W(3s
zR~v4D%?qgvq-;#f%mCF3k-)eViKTSEx$D6v=NUvhSO#dC&<5>#UoveD*M^{}bVkvp
zjX}FBnyUiK_h09orPcXMe_SVQE~Lt7#y_%+=tE4nwy0apxKD^om~m@!e=OCEDqI!c
zE@sVxy}duEuaWEQ7B*~#tA^e#YPMoi{H*nU?_w}KAM6i~UJeg>{o#xKqvMm;gSst9
z`2>wXP}FI+qDI*5cAob-QK#;FcHp2l_2FZZI>)Oqx99vpaZ;D2?X`ip-739cb0
literal 0
HcmV?d00001
diff --git a/src/app/xworkmate/layout.tsx b/src/app/xworkmate/layout.tsx
new file mode 100644
index 0000000..9a6b1c8
--- /dev/null
+++ b/src/app/xworkmate/layout.tsx
@@ -0,0 +1,9 @@
+export const dynamic = "force-dynamic";
+
+export default function XWorkmateLayout({
+ children,
+}: {
+ children: React.ReactNode;
+}) {
+ return children;
+}
diff --git a/src/components/xworkmate/XWorkmateWorkspacePage.tsx b/src/components/xworkmate/XWorkmateWorkspacePage.tsx
index 6e53fcf..2da26c6 100644
--- a/src/components/xworkmate/XWorkmateWorkspacePage.tsx
+++ b/src/components/xworkmate/XWorkmateWorkspacePage.tsx
@@ -12,12 +12,15 @@ import {
Grip,
KeyRound,
ListTodo,
+ Paperclip,
Puzzle,
RefreshCw,
+ Send,
Settings2,
Shield,
Sparkles,
UserCircle2,
+ Zap,
} from "lucide-react";
import { useRouter } from "next/navigation";
@@ -73,7 +76,7 @@ type DetailCardProps = {
meta: string;
};
-function pickCopy(isChinese: boolean, zh: string, en: string): string {
+function pickCopy(isChinese: boolean, zh: T, en: T): T {
return isChinese ? zh : en;
}
@@ -506,6 +509,7 @@ function AssistantHome({
secondaryActionLabel,
connectionHint,
actionDisabled,
+ isSharedProfile,
}: {
isChinese: boolean;
tabs: SectionTab[];
@@ -518,122 +522,122 @@ function AssistantHome({
secondaryActionLabel: string;
connectionHint?: string;
actionDisabled?: boolean;
+ isSharedProfile?: boolean;
}) {
- return (
- <>
-
-
-
-
-
-
-
-
-
- {pickCopy(isChinese, "默认任务", "Default Task")}
-
-
- {pickCopy(
- isChinese,
- "连接 Gateway 后,当前对话会自动作为默认任务开始执行。",
- "After connecting the gateway, the current conversation starts as the default task.",
- )}
-
-
- {tabs.map((tab, index) => (
-
- ))}
-
-
-
- {connected
- ? `${pickCopy(isChinese, "在线", "Online")} · ${endpointLabel}`
- : pickCopy(isChinese, "离线 · 未连接目标", "Offline · No target")}
-
-
-
+ const suggestions = pickCopy(
+ isChinese,
+ [
+ "幻灯片",
+ "视频生成",
+ "深度研究",
+ "文档处理",
+ "数据分析",
+ "可视化",
+ "金融服务",
+ "产品管理",
+ "设计",
+ "邮件编辑",
+ ],
+ [
+ "Slides",
+ "Video Gen",
+ "Deep Research",
+ "Docs Processing",
+ "Data Analysis",
+ "Visualization",
+ "Finance",
+ "Product Management",
+ "Design",
+ "Email Edit",
+ ]
+ );
-
-
-
-
- {pickCopy(isChinese, "先连接 Gateway", "Connect Gateway First")}
-
-
- {pickCopy(
- isChinese,
- "连接后可直接对话、创建任务,并在当前会话查看结果。",
- "Connect first to start chatting, create tasks, and view results in the current conversation.",
- )}
-
- {connectionHint ? (
-
- {connectionHint}
-
- ) : null}
-
+ return (
+
+
+ {!isSharedProfile && (
+
+
+
+
+ {pickCopy(isChinese, "未连接 Gateway", "Gateway Disconnected")}
+
+
+ {connectionHint || pickCopy(isChinese, "请连接 Gateway 以获取完整能力。", "Please connect Gateway for full capabilities.")}
+
+
{primaryActionLabel}
-
-
- {secondaryActionLabel}
-
-
+ )}
-