Add tenant-aware XWorkmate console flows

This commit is contained in:
Haitao Pan 2026-03-17 13:24:41 +08:00
parent 0c4de4dfcd
commit c80fbd1cb1
22 changed files with 2127 additions and 657 deletions

View File

@ -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");

View File

@ -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") {

View File

@ -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<string, string> = { email, password }
const loginBody: Record<string, string> = { 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;
}

View File

@ -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",
},
},
)
);
}

View File

@ -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<string, unknown> | null
mfa?: Record<string, unknown> | null
}
token?: string;
expiresAt?: string;
mfaToken?: string;
error?: string;
retryAt?: string;
user?: Record<string, unknown> | null;
mfa?: Record<string, unknown> | 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",
},
},
)
);
}

View File

@ -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 });
}

View File

@ -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<AccountUser | null> {
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<AccountUser | null> {
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;
}

View File

@ -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 },
);
}
}

View File

@ -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<boolean> {
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<ErrorPayload>({ error: 'not_assuming' }, { status: 400 })
return NextResponse.json<ErrorPayload>(
{ error: "not_assuming" },
{ status: 400 },
);
}
if (!(await verifyRootToken(rootToken))) {
return NextResponse.json<ErrorPayload>({ error: 'root_token_invalid' }, { status: 403 })
return NextResponse.json<ErrorPayload>(
{ 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;
}

View File

@ -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<ErrorPayload>({ error: 'unauthenticated' }, { status: 401 })
return NextResponse.json<ErrorPayload>(
{ error: "unauthenticated" },
{ status: 401 },
);
}
const access = await evaluateAccountAdminAccess(user, {
roles: REQUIRED_ROLES,
permissions: WRITE_PERMISSIONS,
rootOnly: true,
})
});
if (!access.allowed) {
return NextResponse.json<ErrorPayload>({ error: access.reason ?? 'forbidden' }, { status: 403 })
return NextResponse.json<ErrorPayload>(
{ 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<ErrorPayload>({ error: 'invalid_response' }, { status: 502 })
const payload = (await upstream.json().catch(() => null)) as any;
if (!payload || typeof payload.token !== "string") {
return NextResponse.json<ErrorPayload>(
{ 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<ErrorPayload>({ error: 'upstream_unreachable' }, { status: 502 })
console.error("Failed to assume sandbox", error);
return NextResponse.json<ErrorPayload>(
{ error: "upstream_unreachable" },
{ status: 502 },
);
}
}

View File

@ -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 },
);
}
}

View File

@ -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 (
<div className="min-h-[calc(100vh-var(--app-shell-nav-offset))] bg-[linear-gradient(180deg,#f4f7fd_0%,#f6f8fb_32%,#f3f5f8_100%)] px-4 py-5 md:px-6">
<div className="mx-auto max-w-6xl">
<XWorkmateProfileEditor
payload={profile}
scopeKey={scopeKey}
workspaceHref="/xworkmate"
/>
</div>
</div>
);
}

View File

@ -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 (
<div className="min-h-[calc(100vh-var(--app-shell-nav-offset))] bg-[linear-gradient(180deg,#f4f7fd_0%,#f6f8fb_32%,#f3f5f8_100%)] px-4 py-5 md:px-6">
<div className="mx-auto max-w-6xl">
<XWorkmateProfileEditor
payload={profile}
scopeKey={scopeKey}
workspaceHref="/xworkmate"
/>
</div>
</div>
);
}

View File

@ -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 (
<div className="h-[calc(100vh-var(--app-shell-nav-offset))] w-full">
<Suspense fallback={<XWorkmateLoading />}>
<XWorkmateWorkspacePage defaults={defaults} />
<XWorkmateWorkspacePage
defaults={defaults}
profile={profile}
scopeKey={scopeKey}
requestHost={requestHost}
/>
</Suspense>
</div>
);

View File

@ -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 (
<span
className={`inline-flex items-center gap-2 rounded-full px-3 py-1 text-xs font-semibold ${
ok
? "bg-emerald-500/10 text-emerald-600"
: "bg-[var(--color-surface-muted)] text-[var(--color-text-subtle)]"
}`}
>
<span
className={`h-2 w-2 rounded-full ${ok ? "bg-emerald-500" : "bg-[var(--color-text-subtle)]/50"}`}
/>
{label}
</span>
);
}
function Field({
label,
hint,
children,
}: {
label: string;
hint?: string;
children: React.ReactNode;
}) {
return (
<label className="flex flex-col gap-2 text-sm">
<div className="space-y-1">
<span className="font-medium text-[var(--color-text)]">{label}</span>
{hint ? (
<p className="text-xs text-[var(--color-text-subtle)]">{hint}</p>
) : null}
</div>
{children}
</label>
);
}
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<IntegrationDefaults>(
() => 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<ProbeTarget | null>(null);
const [saveState, setSaveState] = useState<string>("");
const [probeResults, setProbeResults] = useState<
Record<ProbeTarget, ProbeState>
>({
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 (
<div className="space-y-5">
<div className="rounded-[28px] border border-[color:var(--color-surface-border)] bg-white/96 px-6 py-5 shadow-[0_18px_50px_rgba(15,23,42,0.06)]">
<div className="flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap gap-2">
<StatusBadge
ok={payload.canEditIntegrations}
label={
payload.profileScope === "tenant-shared"
? "共享版配置"
: "个人独享配置"
}
/>
<StatusBadge
ok={payload.membershipRole === "admin"}
label={`角色 · ${payload.membershipRole}`}
/>
<StatusBadge
ok={summary.some((item) => item.configured)}
label={`${payload.tenant.name} · ${payload.tenant.domain}`}
/>
</div>
<div>
<h1 className="text-[24px] font-semibold tracking-[-0.03em] text-black">
{payload.profileScope === "tenant-shared"
? "共享集成配置"
: "我的集成配置"}
</h1>
<p className="mt-2 max-w-3xl text-sm leading-6 text-[var(--color-text-subtle)]">
{payload.profileScope === "tenant-shared"
? "这组配置对 svc.plus/xworkmate 的共享工作台生效,只有管理员可编辑。"
: "这组配置只对当前租户域名下的你自己生效,不影响其他成员。"}
</p>
</div>
</div>
<div className="flex flex-wrap gap-3">
<Link
href={workspaceHref}
className="inline-flex h-11 items-center rounded-[14px] border border-[color:var(--color-surface-border)] bg-white px-5 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
>
</Link>
<button
type="button"
onClick={saveProfile}
disabled={saving || !payload.canEditIntegrations}
className="inline-flex h-11 items-center gap-2 rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(51,102,255,0.28)] transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
{saving ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<CheckCircle2 className="h-4 w-4" />
)}
</button>
</div>
</div>
{saveState ? (
<p className="mt-4 text-sm text-[var(--color-text-subtle)]">
{saveState}
</p>
) : null}
</div>
<div className="grid gap-4 lg:grid-cols-3">
{summary.map((item) => (
<div
key={item.key}
className="rounded-[22px] border border-[color:var(--color-surface-border)] bg-white/92 p-5 shadow-[var(--shadow-sm)]"
>
<div className="flex items-center justify-between gap-3">
<div>
<p className="text-base font-semibold text-[var(--color-heading)]">
{item.label}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{item.configured ? "已填写连接信息" : "等待配置"}
</p>
</div>
<ShieldCheck className="h-5 w-5 text-[var(--color-primary)]" />
</div>
<div className="mt-4 flex flex-wrap gap-2">
<StatusBadge ok={item.configured} label="地址" />
<StatusBadge ok={item.tokenConfigured} label="凭证" />
</div>
</div>
))}
</div>
<div className="grid gap-5 xl:grid-cols-2">
<div className="space-y-4 rounded-[24px] border border-[color:var(--color-surface-border)] bg-white/96 p-5 shadow-[var(--shadow-sm)]">
<Field label="OpenClaw WebSocket URL">
<input
value={openclawUrl}
onChange={(event) => setOpenclawUrl(event.target.value)}
placeholder="wss://openclaw.svc.plus"
className={inputClassName()}
/>
</Field>
<Field
label="OpenClaw Origin"
hint="留空时允许前端按当前页面 origin 发送。"
>
<input
value={openclawOrigin}
onChange={(event) => setOpenclawOrigin(event.target.value)}
placeholder={`https://${payload.tenant.domain}`}
className={inputClassName()}
/>
</Field>
<Field
label="OpenClaw Token"
hint="仅保留在当前浏览器会话,不会持久化到服务端。"
>
<input
type="password"
value={openclawToken}
onChange={(event) => setOpenclawToken(event.target.value)}
placeholder="Session token only"
className={inputClassName()}
/>
</Field>
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
OpenClaw
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{probeResults.openclaw.error || "检查网关连接和会话 token。"}
</p>
</div>
<button
type="button"
onClick={() => 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" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
<div className="space-y-4 rounded-[24px] border border-[color:var(--color-surface-border)] bg-white/96 p-5 shadow-[var(--shadow-sm)]">
<Field label="Vault URL">
<input
value={vaultUrl}
onChange={(event) => setVaultUrl(event.target.value)}
placeholder="https://vault.svc.plus"
className={inputClassName()}
/>
</Field>
<Field label="Vault Namespace">
<input
value={vaultNamespace}
onChange={(event) => setVaultNamespace(event.target.value)}
placeholder="admin"
className={inputClassName()}
/>
</Field>
<Field
label="Vault Token"
hint="仅用于当前浏览器会话内探测或读取引用。"
>
<input
type="password"
value={vaultToken}
onChange={(event) => setVaultToken(event.target.value)}
placeholder="Session token only"
className={inputClassName()}
/>
</Field>
<Field label="Vault Secret Path">
<input
value={vaultSecretPath}
onChange={(event) => setVaultSecretPath(event.target.value)}
placeholder="kv/openclaw"
className={inputClassName()}
/>
</Field>
<Field label="Vault Secret Key">
<input
value={vaultSecretKey}
onChange={(event) => setVaultSecretKey(event.target.value)}
placeholder="token"
className={inputClassName()}
/>
</Field>
<div className="flex items-center justify-between gap-3 rounded-[18px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
Vault
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{probeResults.vault.error ||
"验证 Vault 地址、namespace 与 token。"}
</p>
</div>
<button
type="button"
onClick={() => 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" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
<div className="rounded-[24px] border border-[color:var(--color-surface-border)] bg-white/96 p-5 shadow-[var(--shadow-sm)]">
<div className="grid gap-4 xl:grid-cols-[1.35fr_0.65fr]">
<Field label="APISIX URL">
<input
value={apisixUrl}
onChange={(event) => setApisixUrl(event.target.value)}
placeholder="https://ai-gateway.svc.plus"
className={inputClassName()}
/>
</Field>
<Field label="APISIX Token" hint="同样只保留在当前浏览器 session。">
<input
type="password"
value={apisixToken}
onChange={(event) => setApisixToken(event.target.value)}
placeholder="Session token only"
className={inputClassName()}
/>
</Field>
</div>
<div className="mt-4 flex items-center justify-between gap-3 rounded-[18px] border border-[color:var(--color-surface-border)] bg-[var(--color-surface-muted)] px-4 py-3">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
APISIX
</p>
<p className="mt-1 text-xs text-[var(--color-text-subtle)]">
{probeResults.apisix.error ||
"验证 AI Gateway 地址和临时 token。"}
</p>
</div>
<button
type="button"
onClick={() => 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" ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<RefreshCw className="h-4 w-4" />
)}
</button>
</div>
</div>
</div>
);
}

View File

@ -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({
<div className="flex flex-wrap items-center gap-2 text-sm font-semibold text-[var(--color-text-subtle)]">
<DesktopChip label={pickCopy(isChinese, "主页", "Home")} />
<ChevronRight className="h-4 w-4" />
<DesktopChip label={pickCopy(isChinese, "默认任务", "Default Task")} />
<DesktopChip
label={pickCopy(isChinese, "默认任务", "Default Task")}
/>
</div>
<h1 className="mt-4 text-[20px] font-semibold tracking-[-0.03em] text-black">
{pickCopy(isChinese, "默认任务", "Default Task")}
@ -520,7 +543,11 @@ function AssistantHome({
</p>
<div className="mt-5 flex flex-wrap gap-3">
{tabs.map((tab, index) => (
<DesktopChip key={tab.key} label={tab.label} active={index === 0} />
<DesktopChip
key={tab.key}
label={tab.label}
active={index === 0}
/>
))}
</div>
</div>
@ -545,22 +572,29 @@ function AssistantHome({
"Connect first to start chatting, create tasks, and view results in the current conversation.",
)}
</p>
{connectionHint ? (
<p className="mt-3 text-sm leading-6 text-[var(--color-text-subtle)]">
{connectionHint}
</p>
) : null}
<div className="mt-6 flex flex-wrap gap-3">
<button
type="button"
onClick={onOpenConnections}
className="inline-flex h-11 items-center gap-2 rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(51,102,255,0.28)] transition hover:bg-[var(--color-primary-hover)]"
disabled={actionDisabled}
className="inline-flex h-11 items-center gap-2 rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white shadow-[0_10px_24px_rgba(51,102,255,0.28)] transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className="h-4 w-4" />
{pickCopy(isChinese, "重新连接", "Reconnect")}
{primaryActionLabel}
</button>
<button
type="button"
onClick={onOpenConnections}
className="inline-flex h-11 items-center gap-2 rounded-[14px] border border-[color:var(--color-surface-border)] bg-white px-5 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)]"
disabled={actionDisabled}
className="inline-flex h-11 items-center gap-2 rounded-[14px] border border-[color:var(--color-surface-border)] bg-white px-5 text-sm font-semibold text-[var(--color-heading)] transition hover:bg-[var(--color-surface-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
<Settings2 className="h-4 w-4" />
{pickCopy(isChinese, "编辑连接", "Edit Connection")}
{secondaryActionLabel}
</button>
</div>
</div>
@ -581,7 +615,9 @@ function AssistantHome({
<div className="mt-4 flex flex-col gap-4 xl:flex-row xl:items-center xl:justify-between">
<div className="flex flex-wrap gap-3">
<ToolbarChip label={pickCopy(isChinese, "远程", "Remote")} />
<ToolbarChip label={pickCopy(isChinese, "默认权限", "Default Access")} />
<ToolbarChip
label={pickCopy(isChinese, "默认权限", "Default Access")}
/>
<ToolbarChip label="z-ai/glm5" active />
<ToolbarChip label={pickCopy(isChinese, "问答", "Ask")} />
<ToolbarChip label={pickCopy(isChinese, "高", "High")} />
@ -589,10 +625,11 @@ function AssistantHome({
<button
type="button"
onClick={onOpenConnections}
className="inline-flex h-11 items-center justify-center gap-2 self-end rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--color-primary-hover)]"
disabled={actionDisabled}
className="inline-flex h-11 items-center justify-center gap-2 self-end rounded-[14px] bg-[var(--color-primary)] px-5 text-sm font-semibold text-white transition hover:bg-[var(--color-primary-hover)] disabled:cursor-not-allowed disabled:opacity-60"
>
<RefreshCw className="h-4 w-4" />
{pickCopy(isChinese, "重连", "Reconnect")}
{primaryActionLabel}
</button>
</div>
</div>
@ -625,12 +662,20 @@ function SectionOverview({
</p>
<div className="mt-5 flex flex-wrap gap-3">
{section.tabs.map((tab, index) => (
<DesktopChip key={tab.key} label={tab.label} active={index === 0} />
<DesktopChip
key={tab.key}
label={tab.label}
active={index === 0}
/>
))}
</div>
</div>
<div className="inline-flex h-fit items-center rounded-full border border-[color:var(--color-surface-border)] bg-white px-4 py-2 text-sm font-semibold text-[var(--color-text-subtle)]">
{pickCopy(isChinese, "已对齐最新桌面结构", "Aligned with latest desktop IA")}
{pickCopy(
isChinese,
"已对齐最新桌面结构",
"Aligned with latest desktop IA",
)}
</div>
</div>
</div>
@ -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<WorkspaceDestination>("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 (
<div className="relative h-full overflow-hidden bg-[linear-gradient(180deg,#f4f7fd_0%,#f6f8fb_32%,#f3f5f8_100%)] text-[var(--color-text)]">
@ -763,6 +863,28 @@ export function XWorkmateWorkspacePage({
<main className="flex min-h-0 flex-1 flex-col rounded-[30px] border border-white/75 bg-[rgba(255,255,255,0.54)] p-3 shadow-[0_24px_64px_rgba(15,23,42,0.07)] backdrop-blur">
<div className="min-h-0 flex-1 rounded-[28px] border border-white/80 bg-[rgba(248,250,252,0.78)] p-3">
<div className="mx-auto flex h-full max-w-[1680px] min-h-0 flex-col">
{profile ? (
<div className="mb-3 flex flex-wrap items-center gap-2 rounded-[22px] border border-[color:var(--color-surface-border)] bg-white/90 px-5 py-4 text-sm text-[var(--color-text-subtle)] shadow-[var(--shadow-sm)]">
<Shield className="h-4 w-4 text-[var(--color-primary)]" />
<span>
{profile.edition === "shared_public"
? pickCopy(isChinese, "共享版", "Shared Edition")
: pickCopy(isChinese, "租户独享版", "Tenant Edition")}
</span>
<span>·</span>
<span>{profile.tenant.name}</span>
<span>·</span>
<span>{profile.membershipRole}</span>
<span>·</span>
<span>{profileModeLabel}</span>
{requestHost ? (
<>
<span>·</span>
<span>{requestHost}</span>
</>
) : null}
</div>
) : null}
{activeSection === "assistant" ? (
<AssistantHome
isChinese={isChinese}
@ -771,10 +893,17 @@ export function XWorkmateWorkspacePage({
connected={connected}
prompt={composerValue}
onPromptChange={setComposerValue}
onOpenConnections={() => setShowConnections(true)}
onOpenConnections={openConnections}
primaryActionLabel={primaryActionLabel}
secondaryActionLabel={secondaryActionLabel}
connectionHint={connectionHint}
actionDisabled={!canEditIntegrations}
/>
) : (
<SectionOverview isChinese={isChinese} section={activeDefinition} />
<SectionOverview
isChinese={isChinese}
section={activeDefinition}
/>
)}
</div>
</div>
@ -787,42 +916,6 @@ export function XWorkmateWorkspacePage({
? `${pickCopy(isChinese, "在线网关", "Gateway Online")} · ${configuredCount}/3`
: `${pickCopy(isChinese, "集成概况", "Integrations")} · ${configuredCount}/3`}
</div>
{showConnections ? (
<div className="absolute inset-0 z-20 flex items-center justify-center bg-[rgba(15,23,42,0.24)] p-6 backdrop-blur-[2px]">
<div className="max-h-[calc(100vh-64px)] w-full max-w-[1080px] overflow-auto rounded-[28px] border border-white/80 bg-[linear-gradient(180deg,#fbfdff_0%,#f6f8fc_100%)] p-5 shadow-[0_32px_80px_rgba(15,23,42,0.20)]">
<div className="mb-4 flex items-center justify-between gap-4 rounded-[20px] border border-[color:var(--color-surface-border)] bg-white/92 px-5 py-4">
<div className="min-w-0">
<p className="text-sm font-semibold text-[var(--color-heading)]">
{pickCopy(isChinese, "编辑 Gateway 连接", "Edit Gateway Connections")}
</p>
<p className="mt-1 text-sm text-[var(--color-text-subtle)]">
{pickCopy(
isChinese,
"沿用当前在线版的配置、探测和会话级覆盖逻辑。",
"Reuse the current web configuration, probe, and session override flow.",
)}
</p>
</div>
<button
type="button"
aria-label="Close"
onClick={() => 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)]"
>
<X className="h-4.5 w-4.5" />
</button>
</div>
<IntegrationsConsole
defaults={defaults}
onOpenAssistant={() => {
setActiveSection("assistant");
setShowConnections(false);
}}
/>
</div>
</div>
) : null}
</div>
);
}

View File

@ -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);
}

39
src/lib/xworkmate/host.ts Normal file
View File

@ -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}`;
}

View File

@ -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(":");
}

View File

@ -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",
});

View File

@ -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<string, string> {
const headers: Record<string, string> = {
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 };
}

View File

@ -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<string, OpenClawScopedSnapshot>;
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<OpenClawConsoleState>()(
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<OpenClawScopedSnapshot>,
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<OpenClawConsoleState>
| 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,
};
},
},
),
)
);