feat: API Tester 支持 MCP 模式 + 修复多个 bug
MCP 模式: - 新增浏览器端 MCP 客户端 (SSE + JSON-RPC + initialize 握手) - API Tester 页面双模式: REST API tab + MCP Tools tab - 连接后自动发现 13 个 tools,支持参数编辑和调用 - 与 AI 客户端走完全相同的协议路径 后端修复: - 每个 SSE 连接创建独立 McpServer 实例,支持多客户端并发 - express.json() 移到 SSE 路由之后,避免消费 MCP 请求体 - 新增 GET /api/xhs/login/cookie-check 轻量接口(不打开浏览器) 前端修复: - QR 轮询改用 cookie-check 接口,不再重复打开浏览器窗口 - useLoginStatus 默认不自动请求,避免页面加载触发浏览器 - Header 添加 Token 未配置警告横幅 - tsup clean:false 保护 dist/web 不被清除
This commit is contained in:
+1
-1
@@ -10,7 +10,7 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"build": "tsup",
|
"build": "tsup",
|
||||||
"build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
|
"build:web": "cd web && npm run build && mkdir -p ../dist/web && cp -r dist/* ../dist/web/",
|
||||||
"build:all": "npm run build && npm run build:web",
|
"build:all": "rm -rf dist/index.js dist/index.js.map && npm run build && npm run build:web",
|
||||||
"dev": "tsup --watch",
|
"dev": "tsup --watch",
|
||||||
"dev:web": "cd web && npm run dev",
|
"dev:web": "cd web && npm run dev",
|
||||||
"start": "node dist/index.js",
|
"start": "node dist/index.js",
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import { logger } from '../../utils/logger.js';
|
|||||||
import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js';
|
import { classifyError, sanitizeErrorMessage } from '../../utils/errors.js';
|
||||||
import { validateMediaPath } from '../../utils/downloader.js';
|
import { validateMediaPath } from '../../utils/downloader.js';
|
||||||
import { rateLimiter } from '../../server/middleware.js';
|
import { rateLimiter } from '../../server/middleware.js';
|
||||||
|
import { cookieStore } from '../../cookie/store.js';
|
||||||
|
|
||||||
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
|
import { checkLoginStatus, getLoginQRCode, deleteCookies } from './login.js';
|
||||||
import { listFeeds } from './feeds.js';
|
import { listFeeds } from './feeds.js';
|
||||||
@@ -217,6 +218,23 @@ export function createXhsRoutes(browser: BrowserManager): Router {
|
|||||||
})();
|
})();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
// GET /login/cookie-check
|
||||||
|
// Lightweight check: does a cookie file exist on disk?
|
||||||
|
// Does NOT open a browser — safe for frequent polling.
|
||||||
|
// -----------------------------------------------------------------------
|
||||||
|
router.get('/login/cookie-check', readRateLimiter, (_req, res) => {
|
||||||
|
void (async () => {
|
||||||
|
try {
|
||||||
|
const state = await cookieStore.load(PLATFORM);
|
||||||
|
const hasCookies = state !== null && state.cookies.length > 0;
|
||||||
|
res.json(successResponse({ hasCookies }) as ApiResponse<{ hasCookies: boolean }>);
|
||||||
|
} catch (err) {
|
||||||
|
handleError(res, err);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
});
|
||||||
|
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
// Content browsing
|
// Content browsing
|
||||||
// =========================================================================
|
// =========================================================================
|
||||||
|
|||||||
+17
-8
@@ -85,9 +85,8 @@ export class AppServer {
|
|||||||
// -- Constructor ----------------------------------------------------------
|
// -- Constructor ----------------------------------------------------------
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
// 1. Express app + body parsing
|
// 1. Express app
|
||||||
this.app = express();
|
this.app = express();
|
||||||
this.app.use(express.json());
|
|
||||||
|
|
||||||
// 2. Security & availability middleware
|
// 2. Security & availability middleware
|
||||||
this.app.use(shutdownGuard(() => this.shuttingDown));
|
this.app.use(shutdownGuard(() => this.shuttingDown));
|
||||||
@@ -97,13 +96,16 @@ export class AppServer {
|
|||||||
{ name: 'social-mcp', version: PACKAGE_VERSION },
|
{ name: 'social-mcp', version: PACKAGE_VERSION },
|
||||||
);
|
);
|
||||||
|
|
||||||
// 4. SSE transport endpoints
|
// 4. SSE transport endpoints (BEFORE body parsing — MCP SDK reads raw body)
|
||||||
this.setupSseEndpoints();
|
this.setupSseEndpoints();
|
||||||
|
|
||||||
// 5. Health endpoint
|
// 5. Body parsing for non-MCP routes
|
||||||
|
this.app.use(express.json());
|
||||||
|
|
||||||
|
// 6. Health endpoint
|
||||||
this.setupHealthEndpoint();
|
this.setupHealthEndpoint();
|
||||||
|
|
||||||
// 6. Bearer token auth for /api/* routes
|
// 7. Bearer token auth for /api/* routes
|
||||||
initBearerToken();
|
initBearerToken();
|
||||||
this.app.use('/api', bearerAuth);
|
this.app.use('/api', bearerAuth);
|
||||||
|
|
||||||
@@ -245,9 +247,16 @@ export class AppServer {
|
|||||||
this.transports.delete(sessionId);
|
this.transports.delete(sessionId);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Connect the transport to the MCP server. This starts the SSE
|
// Each SSE connection needs its own McpServer instance because the
|
||||||
// stream and sends the initial endpoint event to the client.
|
// MCP SDK only allows one transport per server at a time.
|
||||||
void this.mcpServer.connect(transport).catch((err: unknown) => {
|
const perSessionMcp = new McpServer(
|
||||||
|
{ name: 'social-mcp', version: PACKAGE_VERSION },
|
||||||
|
);
|
||||||
|
for (const plugin of this.plugins) {
|
||||||
|
plugin.registerTools(perSessionMcp, browserManager);
|
||||||
|
}
|
||||||
|
|
||||||
|
void perSessionMcp.connect(transport).catch((err: unknown) => {
|
||||||
logger.error({ err, sessionId }, 'Failed to connect SSE transport to MCP server');
|
logger.error({ err, sessionId }, 'Failed to connect SSE transport to MCP server');
|
||||||
this.transports.delete(sessionId);
|
this.transports.delete(sessionId);
|
||||||
});
|
});
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ export default defineConfig({
|
|||||||
format: ['esm'],
|
format: ['esm'],
|
||||||
target: 'node22',
|
target: 'node22',
|
||||||
outDir: 'dist',
|
outDir: 'dist',
|
||||||
clean: true,
|
clean: false,
|
||||||
sourcemap: true,
|
sourcemap: true,
|
||||||
dts: false,
|
dts: false,
|
||||||
splitting: false,
|
splitting: false,
|
||||||
|
|||||||
@@ -27,6 +27,10 @@ export const getLoginQRCode = () =>
|
|||||||
export const deleteCookies = () =>
|
export const deleteCookies = () =>
|
||||||
apiFetch<ApiResponse<{ message: string }>>('/api/xhs/login/cookies', { method: 'DELETE' });
|
apiFetch<ApiResponse<{ message: string }>>('/api/xhs/login/cookies', { method: 'DELETE' });
|
||||||
|
|
||||||
|
// Lightweight cookie check (no browser opened)
|
||||||
|
export const checkLoginCookie = () =>
|
||||||
|
apiFetch<ApiResponse<{ hasCookies: boolean }>>('/api/xhs/login/cookie-check');
|
||||||
|
|
||||||
// Feeds
|
// Feeds
|
||||||
export const listFeeds = () =>
|
export const listFeeds = () =>
|
||||||
apiFetch<ApiResponse<Feed[]>>('/api/xhs/feeds');
|
apiFetch<ApiResponse<Feed[]>>('/api/xhs/feeds');
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
/**
|
||||||
|
* Lightweight MCP client for the browser.
|
||||||
|
*
|
||||||
|
* Connects via SSE, performs the MCP initialize handshake,
|
||||||
|
* then allows tools/list and tools/call operations.
|
||||||
|
*/
|
||||||
|
|
||||||
|
let sseSessionId: string | null = null;
|
||||||
|
let sseSource: EventSource | null = null;
|
||||||
|
let initialized = false;
|
||||||
|
const pendingResolvers = new Map<number, {
|
||||||
|
resolve: (value: unknown) => void;
|
||||||
|
reject: (reason: unknown) => void;
|
||||||
|
timer: ReturnType<typeof setTimeout>;
|
||||||
|
}>();
|
||||||
|
let jsonRpcId = 1;
|
||||||
|
|
||||||
|
const getBaseUrl = (): string => localStorage.getItem('smcp_server_url') || '';
|
||||||
|
|
||||||
|
export type McpConnectionState = 'disconnected' | 'connecting' | 'connected';
|
||||||
|
|
||||||
|
let connectionState: McpConnectionState = 'disconnected';
|
||||||
|
let onStateChange: ((state: McpConnectionState) => void) | null = null;
|
||||||
|
|
||||||
|
export function onConnectionStateChange(cb: (state: McpConnectionState) => void) {
|
||||||
|
onStateChange = cb;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMcpConnectionState(): McpConnectionState {
|
||||||
|
return connectionState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setState(s: McpConnectionState) {
|
||||||
|
connectionState = s;
|
||||||
|
onStateChange?.(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the MCP server via SSE and perform the initialization handshake.
|
||||||
|
*/
|
||||||
|
export async function connect(): Promise<void> {
|
||||||
|
if (sseSource && sseSessionId && initialized) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setState('connecting');
|
||||||
|
|
||||||
|
// Step 1: Establish SSE connection and get sessionId
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
const es = new EventSource(`${baseUrl}/sse`);
|
||||||
|
sseSource = es;
|
||||||
|
|
||||||
|
es.addEventListener('endpoint', (ev: MessageEvent) => {
|
||||||
|
const url = new URL(ev.data as string, window.location.origin);
|
||||||
|
sseSessionId = url.searchParams.get('sessionId');
|
||||||
|
resolve();
|
||||||
|
});
|
||||||
|
|
||||||
|
es.addEventListener('message', (ev: MessageEvent) => {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(ev.data as string) as { id?: number; result?: unknown; error?: unknown };
|
||||||
|
if (data.id != null) {
|
||||||
|
const pending = pendingResolvers.get(data.id);
|
||||||
|
if (pending) {
|
||||||
|
pendingResolvers.delete(pending.timer as unknown as number);
|
||||||
|
clearTimeout(pending.timer);
|
||||||
|
pendingResolvers.delete(data.id);
|
||||||
|
if (data.error) {
|
||||||
|
pending.reject(data.error);
|
||||||
|
} else {
|
||||||
|
pending.resolve(data.result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
es.onerror = () => {
|
||||||
|
if (connectionState === 'connecting' && !sseSessionId) {
|
||||||
|
reject(new Error('Failed to connect to MCP SSE endpoint'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Send initialize request
|
||||||
|
const initResult = await sendJsonRpc('initialize', {
|
||||||
|
protocolVersion: '2024-11-05',
|
||||||
|
capabilities: {},
|
||||||
|
clientInfo: { name: 'social-mcp-dashboard', version: '0.1.0' },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!initResult) {
|
||||||
|
throw new Error('Initialize returned empty result');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: Send initialized notification (no id = notification)
|
||||||
|
await sendNotification('notifications/initialized', {});
|
||||||
|
|
||||||
|
initialized = true;
|
||||||
|
setState('connected');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disconnect from the MCP server.
|
||||||
|
*/
|
||||||
|
export function disconnect() {
|
||||||
|
if (sseSource) {
|
||||||
|
sseSource.close();
|
||||||
|
sseSource = null;
|
||||||
|
}
|
||||||
|
sseSessionId = null;
|
||||||
|
initialized = false;
|
||||||
|
for (const [, p] of pendingResolvers) {
|
||||||
|
clearTimeout(p.timer);
|
||||||
|
p.reject(new Error('Disconnected'));
|
||||||
|
}
|
||||||
|
pendingResolvers.clear();
|
||||||
|
setState('disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface McpToolInfo {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
inputSchema?: {
|
||||||
|
type: string;
|
||||||
|
properties?: Record<string, {
|
||||||
|
type?: string;
|
||||||
|
description?: string;
|
||||||
|
enum?: string[];
|
||||||
|
default?: unknown;
|
||||||
|
items?: unknown;
|
||||||
|
}>;
|
||||||
|
required?: string[];
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available MCP tools.
|
||||||
|
*/
|
||||||
|
export async function listTools(): Promise<{ tools: McpToolInfo[] }> {
|
||||||
|
const result = await sendJsonRpc('tools/list', {});
|
||||||
|
return result as { tools: McpToolInfo[] };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Call an MCP tool by name with arguments.
|
||||||
|
*/
|
||||||
|
export async function callTool(name: string, args: Record<string, unknown>): Promise<unknown> {
|
||||||
|
return sendJsonRpc('tools/call', { name, arguments: args });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON-RPC request (with id, expects response).
|
||||||
|
*/
|
||||||
|
function sendJsonRpc(method: string, params: unknown): Promise<unknown> {
|
||||||
|
const id = jsonRpcId++;
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id,
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
pendingResolvers.delete(id);
|
||||||
|
reject(new Error(`MCP request "${method}" timed out (5m)`));
|
||||||
|
}, 300_000);
|
||||||
|
|
||||||
|
pendingResolvers.set(id, { resolve, reject, timer });
|
||||||
|
|
||||||
|
fetch(`${baseUrl}/messages?sessionId=${sseSessionId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
}).then((res) => {
|
||||||
|
if (!res.ok) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingResolvers.delete(id);
|
||||||
|
return res.text().then((text) => {
|
||||||
|
reject(new Error(`POST /messages failed: ${res.status} - ${text}`));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch((err) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
pendingResolvers.delete(id);
|
||||||
|
reject(err);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send a JSON-RPC notification (no id, no response expected).
|
||||||
|
*/
|
||||||
|
async function sendNotification(method: string, params: unknown): Promise<void> {
|
||||||
|
const baseUrl = getBaseUrl();
|
||||||
|
|
||||||
|
const body = JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
method,
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/messages?sessionId=${sseSessionId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`Notification "${method}" failed: ${res.status} - ${text}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
+362
-93
@@ -1,16 +1,32 @@
|
|||||||
import { useState, useCallback } from 'react';
|
import { useState, useCallback, useEffect } from 'react';
|
||||||
import { Card } from '@/components/ui/Card';
|
import { Card } from '@/components/ui/Card';
|
||||||
import { Button } from '@/components/ui/Button';
|
import { Button } from '@/components/ui/Button';
|
||||||
import { Select } from '@/components/ui/Select';
|
import { Select } from '@/components/ui/Select';
|
||||||
import { Badge } from '@/components/ui/Badge';
|
import { Badge } from '@/components/ui/Badge';
|
||||||
|
import { Tabs } from '@/components/ui/Tabs';
|
||||||
import { JsonViewer } from '@/components/ui/JsonViewer';
|
import { JsonViewer } from '@/components/ui/JsonViewer';
|
||||||
|
import { Spinner } from '@/components/ui/Spinner';
|
||||||
import { API_ENDPOINTS } from '@/lib/constants';
|
import { API_ENDPOINTS } from '@/lib/constants';
|
||||||
import { apiFetch, generateCurl } from '@/api/client';
|
import { apiFetch, generateCurl } from '@/api/client';
|
||||||
|
import {
|
||||||
|
connect,
|
||||||
|
disconnect,
|
||||||
|
listTools,
|
||||||
|
callTool,
|
||||||
|
getMcpConnectionState,
|
||||||
|
onConnectionStateChange,
|
||||||
|
type McpToolInfo,
|
||||||
|
type McpConnectionState,
|
||||||
|
} from '@/api/mcp-client';
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Shared types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
interface HistoryEntry {
|
interface HistoryEntry {
|
||||||
id: number;
|
id: number;
|
||||||
method: string;
|
mode: 'REST' | 'MCP';
|
||||||
path: string;
|
label: string;
|
||||||
status: 'success' | 'error';
|
status: 'success' | 'error';
|
||||||
time: string;
|
time: string;
|
||||||
duration: number;
|
duration: number;
|
||||||
@@ -19,16 +35,145 @@ interface HistoryEntry {
|
|||||||
|
|
||||||
let historyId = 0;
|
let historyId = 0;
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Page
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
export function ApiTesterPage() {
|
export function ApiTesterPage() {
|
||||||
const [selectedKey, setSelectedKey] = useState<string>(API_ENDPOINTS[0]!.key);
|
const [mode, setMode] = useState<'rest' | 'mcp'>('rest');
|
||||||
const [bodyText, setBodyText] = useState('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [response, setResponse] = useState<unknown>(null);
|
const [response, setResponse] = useState<unknown>(null);
|
||||||
const [responseStatus, setResponseStatus] = useState<number | null>(null);
|
const [responseStatus, setResponseStatus] = useState<'success' | 'error' | null>(null);
|
||||||
const [duration, setDuration] = useState<number | null>(null);
|
const [duration, setDuration] = useState<number | null>(null);
|
||||||
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
const [history, setHistory] = useState<HistoryEntry[]>([]);
|
||||||
|
|
||||||
|
const addHistory = useCallback((entry: Omit<HistoryEntry, 'id'>) => {
|
||||||
|
setHistory((prev) => [{ ...entry, id: historyId++ }, ...prev].slice(0, 30));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold">API Tester</h1>
|
||||||
|
|
||||||
|
<Tabs
|
||||||
|
tabs={[
|
||||||
|
{ key: 'rest', label: 'REST API' },
|
||||||
|
{ key: 'mcp', label: 'MCP Tools' },
|
||||||
|
]}
|
||||||
|
active={mode}
|
||||||
|
onChange={(k) => setMode(k as 'rest' | 'mcp')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||||
|
<div className="lg:col-span-2 space-y-4">
|
||||||
|
{mode === 'rest' ? (
|
||||||
|
<RestPanel
|
||||||
|
response={response}
|
||||||
|
setResponse={setResponse}
|
||||||
|
setResponseStatus={setResponseStatus}
|
||||||
|
setDuration={setDuration}
|
||||||
|
addHistory={addHistory}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<McpPanel
|
||||||
|
response={response}
|
||||||
|
setResponse={setResponse}
|
||||||
|
setResponseStatus={setResponseStatus}
|
||||||
|
setDuration={setDuration}
|
||||||
|
addHistory={addHistory}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Response */}
|
||||||
|
{response !== null && (
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">Response</h2>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{responseStatus && (
|
||||||
|
<Badge variant={responseStatus === 'success' ? 'success' : 'danger'}>
|
||||||
|
{responseStatus}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{duration !== null && (
|
||||||
|
<span className="text-xs text-dark-muted">{duration}ms</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<JsonViewer data={response} maxHeight="500px" />
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* History */}
|
||||||
|
<div>
|
||||||
|
<Card>
|
||||||
|
<div className="flex items-center justify-between mb-3">
|
||||||
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">History</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setHistory([])}>Clear</Button>
|
||||||
|
</div>
|
||||||
|
{history.length === 0 ? (
|
||||||
|
<p className="text-sm text-dark-muted">No requests yet</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
||||||
|
{history.map((entry) => (
|
||||||
|
<div
|
||||||
|
key={entry.id}
|
||||||
|
className="p-2 rounded-lg border border-dark-border/50 hover:bg-dark-hover cursor-pointer text-xs"
|
||||||
|
onClick={() => {
|
||||||
|
setResponse(entry.response);
|
||||||
|
setResponseStatus(entry.status);
|
||||||
|
setDuration(entry.duration);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge
|
||||||
|
variant={entry.mode === 'MCP' ? 'info' : 'success'}
|
||||||
|
className="text-[10px]"
|
||||||
|
>
|
||||||
|
{entry.mode}
|
||||||
|
</Badge>
|
||||||
|
<span className="font-mono truncate">{entry.label}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-1 text-dark-muted">
|
||||||
|
<Badge variant={entry.status === 'success' ? 'success' : 'danger'} className="text-[10px]">
|
||||||
|
{entry.status}
|
||||||
|
</Badge>
|
||||||
|
<span>{entry.time}</span>
|
||||||
|
<span>{entry.duration}ms</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// REST Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function RestPanel({
|
||||||
|
setResponse,
|
||||||
|
setResponseStatus,
|
||||||
|
setDuration,
|
||||||
|
addHistory,
|
||||||
|
}: {
|
||||||
|
response: unknown;
|
||||||
|
setResponse: (v: unknown) => void;
|
||||||
|
setResponseStatus: (v: 'success' | 'error' | null) => void;
|
||||||
|
setDuration: (v: number | null) => void;
|
||||||
|
addHistory: (e: Omit<HistoryEntry, 'id'>) => void;
|
||||||
|
}) {
|
||||||
|
const [selectedKey, setSelectedKey] = useState<string>(API_ENDPOINTS[0]!.key);
|
||||||
|
const [bodyText, setBodyText] = useState('');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!;
|
const endpoint = API_ENDPOINTS.find((e) => e.key === selectedKey)!;
|
||||||
|
const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))];
|
||||||
|
|
||||||
const handleEndpointChange = useCallback((key: string) => {
|
const handleEndpointChange = useCallback((key: string) => {
|
||||||
setSelectedKey(key);
|
setSelectedKey(key);
|
||||||
@@ -41,7 +186,7 @@ export function ApiTesterPage() {
|
|||||||
} else {
|
} else {
|
||||||
setBodyText('');
|
setBodyText('');
|
||||||
}
|
}
|
||||||
}, []);
|
}, [setResponse, setResponseStatus, setDuration]);
|
||||||
|
|
||||||
const handleSend = useCallback(async () => {
|
const handleSend = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -58,43 +203,31 @@ export function ApiTesterPage() {
|
|||||||
});
|
});
|
||||||
const dur = Date.now() - start;
|
const dur = Date.now() - start;
|
||||||
setResponse(res);
|
setResponse(res);
|
||||||
setResponseStatus(200);
|
setResponseStatus('success');
|
||||||
setDuration(dur);
|
setDuration(dur);
|
||||||
const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res };
|
addHistory({ mode: 'REST', label: `${endpoint.method} ${endpoint.path}`, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res });
|
||||||
setHistory((prev) => [entry, ...prev].slice(0, 20));
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const dur = Date.now() - start;
|
const dur = Date.now() - start;
|
||||||
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
|
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
|
||||||
setResponse(errData);
|
setResponse(errData);
|
||||||
setResponseStatus((err as { status?: number }).status || 500);
|
setResponseStatus('error');
|
||||||
setDuration(dur);
|
setDuration(dur);
|
||||||
const entry: HistoryEntry = { id: historyId++, method: endpoint.method, path: endpoint.path, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData };
|
addHistory({ mode: 'REST', label: `${endpoint.method} ${endpoint.path}`, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData });
|
||||||
setHistory((prev) => [entry, ...prev].slice(0, 20));
|
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}, [endpoint, bodyText]);
|
}, [endpoint, bodyText, setResponse, setResponseStatus, setDuration, addHistory]);
|
||||||
|
|
||||||
const curl = generateCurl(
|
const curl = generateCurl(
|
||||||
endpoint.method,
|
endpoint.method,
|
||||||
endpoint.path,
|
endpoint.path,
|
||||||
bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText); } catch { return undefined; } })() : undefined,
|
bodyText.trim() && endpoint.method !== 'GET' ? (() => { try { return JSON.parse(bodyText) as unknown; } catch { return undefined; } })() : undefined,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Group endpoints by category
|
|
||||||
const categories = [...new Set(API_ENDPOINTS.map((e) => e.category))];
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<>
|
||||||
<h1 className="text-2xl font-bold">API Tester</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
|
||||||
{/* Left: Request */}
|
|
||||||
<div className="lg:col-span-2 space-y-4">
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">Request</h2>
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">REST Request</h2>
|
||||||
|
|
||||||
{/* Endpoint selector */}
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Select
|
<Select
|
||||||
label="Endpoint"
|
label="Endpoint"
|
||||||
@@ -112,15 +245,12 @@ export function ApiTesterPage() {
|
|||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Badge variant={endpoint.method === 'GET' ? 'success' : endpoint.method === 'DELETE' ? 'danger' : 'info'}>
|
<Badge variant={endpoint.method === 'GET' ? 'success' : endpoint.method === 'DELETE' ? 'danger' : 'info'}>
|
||||||
{endpoint.method}
|
{endpoint.method}
|
||||||
</Badge>
|
</Badge>
|
||||||
<code className="text-sm text-dark-text font-mono">{endpoint.path}</code>
|
<code className="text-sm text-dark-text font-mono">{endpoint.path}</code>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Body editor */}
|
|
||||||
{endpoint.method !== 'GET' && (
|
{endpoint.method !== 'GET' && (
|
||||||
<div className="flex flex-col gap-1.5">
|
<div className="flex flex-col gap-1.5">
|
||||||
<label className="text-sm text-dark-muted">Request Body (JSON)</label>
|
<label className="text-sm text-dark-muted">Request Body (JSON)</label>
|
||||||
@@ -132,89 +262,228 @@ export function ApiTesterPage() {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
<Button onClick={() => void handleSend()} loading={loading}>
|
<Button onClick={() => void handleSend()} loading={loading}>Send Request</Button>
|
||||||
Send Request
|
<Button variant="ghost" size="sm" onClick={() => void navigator.clipboard.writeText(curl)}>Copy cURL</Button>
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={() => void navigator.clipboard.writeText(curl)}
|
|
||||||
>
|
|
||||||
Copy cURL
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* cURL preview */}
|
|
||||||
<Card>
|
<Card>
|
||||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">cURL</h2>
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-2">cURL</h2>
|
||||||
<pre className="bg-dark-bg border border-dark-border rounded-lg p-3 text-xs font-mono text-dark-text overflow-x-auto whitespace-pre-wrap">
|
<pre className="bg-dark-bg border border-dark-border rounded-lg p-3 text-xs font-mono text-dark-text overflow-x-auto whitespace-pre-wrap">{curl}</pre>
|
||||||
{curl}
|
|
||||||
</pre>
|
|
||||||
</Card>
|
</Card>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
{/* Response */}
|
// ---------------------------------------------------------------------------
|
||||||
{response !== null && (
|
// MCP Panel
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function McpPanel({
|
||||||
|
setResponse,
|
||||||
|
setResponseStatus,
|
||||||
|
setDuration,
|
||||||
|
addHistory,
|
||||||
|
}: {
|
||||||
|
response: unknown;
|
||||||
|
setResponse: (v: unknown) => void;
|
||||||
|
setResponseStatus: (v: 'success' | 'error' | null) => void;
|
||||||
|
setDuration: (v: number | null) => void;
|
||||||
|
addHistory: (e: Omit<HistoryEntry, 'id'>) => void;
|
||||||
|
}) {
|
||||||
|
const [connState, setConnState] = useState<McpConnectionState>(getMcpConnectionState());
|
||||||
|
const [connecting, setConnecting] = useState(false);
|
||||||
|
const [tools, setTools] = useState<McpToolInfo[]>([]);
|
||||||
|
const [selectedTool, setSelectedTool] = useState('');
|
||||||
|
const [argsText, setArgsText] = useState('{}');
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
onConnectionStateChange(setConnState);
|
||||||
|
return () => onConnectionStateChange(() => {});
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleConnect = useCallback(async () => {
|
||||||
|
setConnecting(true);
|
||||||
|
try {
|
||||||
|
await connect();
|
||||||
|
const result = await listTools();
|
||||||
|
setTools(result.tools);
|
||||||
|
if (result.tools.length > 0 && !selectedTool) {
|
||||||
|
setSelectedTool(result.tools[0]!.name);
|
||||||
|
// Generate default args from schema
|
||||||
|
const schema = result.tools[0]!.inputSchema;
|
||||||
|
setArgsText(generateDefaultArgs(schema));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
setResponse({ error: err instanceof Error ? err.message : String(err) });
|
||||||
|
setResponseStatus('error');
|
||||||
|
} finally {
|
||||||
|
setConnecting(false);
|
||||||
|
}
|
||||||
|
}, [selectedTool, setResponse, setResponseStatus]);
|
||||||
|
|
||||||
|
const handleDisconnect = useCallback(() => {
|
||||||
|
disconnect();
|
||||||
|
setTools([]);
|
||||||
|
setSelectedTool('');
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleToolChange = useCallback((name: string) => {
|
||||||
|
setSelectedTool(name);
|
||||||
|
setResponse(null);
|
||||||
|
setResponseStatus(null);
|
||||||
|
setDuration(null);
|
||||||
|
const tool = tools.find((t) => t.name === name);
|
||||||
|
if (tool) {
|
||||||
|
setArgsText(generateDefaultArgs(tool.inputSchema));
|
||||||
|
}
|
||||||
|
}, [tools, setResponse, setResponseStatus, setDuration]);
|
||||||
|
|
||||||
|
const handleCall = useCallback(async () => {
|
||||||
|
if (!selectedTool) return;
|
||||||
|
setLoading(true);
|
||||||
|
setResponse(null);
|
||||||
|
const start = Date.now();
|
||||||
|
try {
|
||||||
|
let args: Record<string, unknown> = {};
|
||||||
|
if (argsText.trim()) {
|
||||||
|
args = JSON.parse(argsText) as Record<string, unknown>;
|
||||||
|
}
|
||||||
|
const res = await callTool(selectedTool, args);
|
||||||
|
const dur = Date.now() - start;
|
||||||
|
setResponse(res);
|
||||||
|
setResponseStatus('success');
|
||||||
|
setDuration(dur);
|
||||||
|
addHistory({ mode: 'MCP', label: selectedTool, status: 'success', time: new Date().toLocaleTimeString(), duration: dur, response: res });
|
||||||
|
} catch (err) {
|
||||||
|
const dur = Date.now() - start;
|
||||||
|
const errData = err instanceof Error ? { error: err.message } : { error: String(err) };
|
||||||
|
setResponse(errData);
|
||||||
|
setResponseStatus('error');
|
||||||
|
setDuration(dur);
|
||||||
|
addHistory({ mode: 'MCP', label: selectedTool, status: 'error', time: new Date().toLocaleTimeString(), duration: dur, response: errData });
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, [selectedTool, argsText, setResponse, setResponseStatus, setDuration, addHistory]);
|
||||||
|
|
||||||
|
const currentTool = tools.find((t) => t.name === selectedTool);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Connection */}
|
||||||
<Card>
|
<Card>
|
||||||
<div className="flex items-center justify-between mb-2">
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">MCP Connection</h2>
|
||||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">Response</h2>
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<Badge variant={connState === 'connected' ? 'success' : connState === 'connecting' ? 'warning' : 'default'}>
|
||||||
{responseStatus && (
|
{connState}
|
||||||
<Badge variant={responseStatus < 300 ? 'success' : 'danger'}>
|
|
||||||
{responseStatus}
|
|
||||||
</Badge>
|
</Badge>
|
||||||
|
{connState === 'disconnected' && (
|
||||||
|
<Button size="sm" onClick={() => void handleConnect()} loading={connecting}>
|
||||||
|
Connect
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{duration !== null && (
|
{connState === 'connected' && (
|
||||||
<span className="text-xs text-dark-muted">{duration}ms</span>
|
<>
|
||||||
|
<span className="text-xs text-dark-muted">{tools.length} tools available</span>
|
||||||
|
<Button size="sm" variant="ghost" onClick={handleDisconnect}>Disconnect</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
{connState === 'connecting' && <Spinner size="sm" />}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<JsonViewer data={response} maxHeight="500px" />
|
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: History */}
|
{/* Tool Call */}
|
||||||
|
{connState === 'connected' && tools.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider mb-3">MCP Tool Call</h2>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<Select
|
||||||
|
label="Tool"
|
||||||
|
options={tools.map((t) => ({ value: t.name, label: t.name }))}
|
||||||
|
value={selectedTool}
|
||||||
|
onChange={(e) => handleToolChange(e.target.value)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Tool info */}
|
||||||
|
{currentTool && (
|
||||||
|
<div className="bg-dark-bg rounded-lg p-3 text-xs space-y-2">
|
||||||
|
{currentTool.description && (
|
||||||
|
<p className="text-dark-muted">{currentTool.description}</p>
|
||||||
|
)}
|
||||||
|
{currentTool.inputSchema?.properties && (
|
||||||
<div>
|
<div>
|
||||||
<Card>
|
<p className="text-dark-muted font-semibold mb-1">Parameters:</p>
|
||||||
<div className="flex items-center justify-between mb-3">
|
{Object.entries(currentTool.inputSchema.properties).map(([key, prop]) => (
|
||||||
<h2 className="text-sm font-semibold text-dark-muted uppercase tracking-wider">History</h2>
|
<div key={key} className="flex gap-2 ml-2">
|
||||||
<Button variant="ghost" size="sm" onClick={() => setHistory([])}>Clear</Button>
|
<code className="text-dark-accent">{key}</code>
|
||||||
</div>
|
<span className="text-dark-muted">
|
||||||
{history.length === 0 ? (
|
{prop.type || ''}
|
||||||
<p className="text-sm text-dark-muted">No requests yet</p>
|
{currentTool.inputSchema?.required?.includes(key) ? ' (required)' : ' (optional)'}
|
||||||
) : (
|
{prop.description ? ` — ${prop.description}` : ''}
|
||||||
<div className="space-y-2 max-h-[600px] overflow-y-auto">
|
</span>
|
||||||
{history.map((entry) => (
|
|
||||||
<div
|
|
||||||
key={entry.id}
|
|
||||||
className="p-2 rounded-lg border border-dark-border/50 hover:bg-dark-hover cursor-pointer text-xs"
|
|
||||||
onClick={() => {
|
|
||||||
setResponse(entry.response);
|
|
||||||
setResponseStatus(entry.status === 'success' ? 200 : 500);
|
|
||||||
setDuration(entry.duration);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Badge variant={entry.status === 'success' ? 'success' : 'danger'} className="text-[10px]">
|
|
||||||
{entry.method}
|
|
||||||
</Badge>
|
|
||||||
<span className="font-mono truncate">{entry.path}</span>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-1 text-dark-muted">
|
|
||||||
<span>{entry.time}</span>
|
|
||||||
<span>{entry.duration}ms</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Arguments editor */}
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
<label className="text-sm text-dark-muted">Arguments (JSON)</label>
|
||||||
|
<textarea
|
||||||
|
value={argsText}
|
||||||
|
onChange={(e) => setArgsText(e.target.value)}
|
||||||
|
className="bg-dark-bg border border-dark-border rounded-lg px-3 py-2 text-sm text-dark-text font-mono focus:outline-none focus:border-dark-accent transition-colors resize-y min-h-[120px]"
|
||||||
|
spellCheck={false}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button onClick={() => void handleCall()} loading={loading}>
|
||||||
|
Call Tool
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
</Card>
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{connState === 'disconnected' && (
|
||||||
|
<Card>
|
||||||
|
<div className="text-center py-8 text-dark-muted">
|
||||||
|
<p>Connect to the MCP server to discover and test tools.</p>
|
||||||
|
<p className="text-xs mt-2">This uses the same SSE + JSON-RPC protocol that AI clients use.</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</Card>
|
||||||
</div>
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Helpers
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
function generateDefaultArgs(schema?: McpToolInfo['inputSchema']): string {
|
||||||
|
if (!schema?.properties) return '{}';
|
||||||
|
const obj: Record<string, unknown> = {};
|
||||||
|
for (const [key, prop] of Object.entries(schema.properties)) {
|
||||||
|
if (prop.default !== undefined) {
|
||||||
|
obj[key] = prop.default;
|
||||||
|
} else if (prop.enum && prop.enum.length > 0) {
|
||||||
|
obj[key] = prop.enum[0];
|
||||||
|
} else if (prop.type === 'string') {
|
||||||
|
obj[key] = '';
|
||||||
|
} else if (prop.type === 'boolean') {
|
||||||
|
obj[key] = false;
|
||||||
|
} else if (prop.type === 'number' || prop.type === 'integer') {
|
||||||
|
obj[key] = 0;
|
||||||
|
} else if (prop.type === 'array') {
|
||||||
|
obj[key] = [];
|
||||||
|
} else {
|
||||||
|
obj[key] = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return JSON.stringify(obj, null, 2);
|
||||||
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import { Spinner } from '@/components/ui/Spinner';
|
|||||||
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
import { useLoginStatus } from '@/hooks/useLoginStatus';
|
||||||
import { useAuth } from '@/context/AuthContext';
|
import { useAuth } from '@/context/AuthContext';
|
||||||
import { useToast } from '@/context/ToastContext';
|
import { useToast } from '@/context/ToastContext';
|
||||||
import { getLoginQRCode, deleteCookies, getLoginStatus } from '@/api/endpoints';
|
import { getLoginQRCode, deleteCookies, checkLoginCookie } from '@/api/endpoints';
|
||||||
|
|
||||||
export function LoginPage() {
|
export function LoginPage() {
|
||||||
const { status, loading: statusLoading, refresh: refreshStatus } = useLoginStatus();
|
const { status, loading: statusLoading, refresh: refreshStatus } = useLoginStatus();
|
||||||
@@ -48,11 +48,11 @@ export function LoginPage() {
|
|||||||
setCountdown(240); // 4 min
|
setCountdown(240); // 4 min
|
||||||
pollRef.current = setInterval(async () => {
|
pollRef.current = setInterval(async () => {
|
||||||
try {
|
try {
|
||||||
const statusRes = await getLoginStatus();
|
const cookieRes = await checkLoginCookie();
|
||||||
if (statusRes.success && statusRes.data?.loggedIn) {
|
if (cookieRes.success && cookieRes.data?.hasCookies) {
|
||||||
stopPolling();
|
stopPolling();
|
||||||
setQrData(null);
|
setQrData(null);
|
||||||
toast('success', `Logged in as ${statusRes.data.username || 'user'}`);
|
toast('success', 'Login successful!');
|
||||||
void refreshStatus();
|
void refreshStatus();
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
|
|||||||
Reference in New Issue
Block a user