Add tenant-aware XWorkmate console flows
This commit is contained in:
parent
0c4de4dfcd
commit
5d9de8ed1f
@ -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");
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@ -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",
|
||||
},
|
||||
},
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
24
src/app/api/auth/oauth/login/[provider]/route.ts
Normal file
24
src/app/api/auth/oauth/login/[provider]/route.ts
Normal 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 });
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
81
src/app/api/xworkmate/profile/route.ts
Normal file
81
src/app/api/xworkmate/profile/route.ts
Normal 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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
56
src/app/xworkmate/admin/page.tsx
Normal file
56
src/app/xworkmate/admin/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
src/app/xworkmate/integrations/page.tsx
Normal file
53
src/app/xworkmate/integrations/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
|
||||
524
src/components/xworkmate/XWorkmateProfileEditor.tsx
Normal file
524
src/components/xworkmate/XWorkmateProfileEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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
39
src/lib/xworkmate/host.ts
Normal 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}`;
|
||||
}
|
||||
76
src/lib/xworkmate/types.ts
Normal file
76
src/lib/xworkmate/types.ts
Normal 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(":");
|
||||
}
|
||||
@ -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",
|
||||
});
|
||||
|
||||
73
src/server/xworkmate/profile.ts
Normal file
73
src/server/xworkmate/profile.ts
Normal 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 };
|
||||
}
|
||||
@ -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,
|
||||
};
|
||||
},
|
||||
},
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
Loading…
Reference in New Issue
Block a user