diff --git a/.gitignore b/.gitignore index c9ba4dd7f..150105912 100644 --- a/.gitignore +++ b/.gitignore @@ -32,6 +32,7 @@ packages/web/public/worker-*.js .kiro/ .trae/ .dare/ +.claude/ .codex/ .gemini/ *.swp diff --git a/docs/local-patches.md b/docs/local-patches.md new file mode 100644 index 000000000..7ed6bf390 --- /dev/null +++ b/docs/local-patches.md @@ -0,0 +1,63 @@ +--- +doc_kind: tracking +created: 2026-03-30 +--- + +# Local Patches (upstream divergence tracking) + +Tracks commits that are **local customizations** not intended for upstream. +During `sync` from upstream, these need to be rebased or re-applied. + +## Active Patches + +### Feishu QR Code Bind Flow + +> **Status**: active | **Since**: 2026-03-29 | **Branch**: feat/feishu-qr-bind + +飞书扫码绑定 connector 的完整流程,Clowder 特有业务功能。 + +**Commits** (oldest first): +| Commit | Description | +|--------|-------------| +| `d85f0fb` | feat(connector): support feishu qr bind flow in hub | +| `2beb523` | fix(ci): format HubConnectorConfigTab for biome | +| `321e8c7` | fix(review): tighten connector save hint and stabilize feishu qr polling | +| `09391f9` | fix(test): isolate env vars in Feishu QR credential-persist test | + +**Files touched** (conflict risk during sync): +- `packages/api/src/routes/connector-hub.ts` — 215 lines added (new routes) +- `packages/web/src/components/HubConnectorConfigTab.tsx` — import + render changes +- `packages/web/src/components/FeishuQrPanel.tsx` — new file +- `packages/api/test/connector-hub-route.test.js` — new test +- `packages/web/src/components/__tests__/feishu-qr-panel.test.tsx` — new test +- `packages/web/src/components/__tests__/hub-connector-config-tab.test.tsx` — new test + +### Connector Admin Hint + +> **Status**: PR'd upstream (zts212653/clowder-ai#308) | **Since**: 2026-03-30 + +管理员权限报错时显示 open_id hint。 + +**Commits**: +| Commit | Description | +|--------|-------------| +| `80824d6` | fix(connector): show open_id hint when non-admin uses permission commands | + +**Files touched**: +- `packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts` + +**Action**: upstream 合并后,下次 sync 自动消除。可在 sync 后从此文档移除。 + +## Resolved Patches + +_None yet._ + +--- + +## Sync Checklist + +每次从上游 sync 后: +1. `git rebase` active patches onto new sync commit +2. 解决冲突时参照上面的 files-touched 列表 +3. 检查 PR'd 的 patch 是否已被上游合并 → 移到 Resolved +4. 跑 `pnpm check && pnpm lint && pnpm test` 确认无回归 diff --git a/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts b/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts index 9d84e1002..ad37026aa 100644 --- a/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts +++ b/packages/api/src/infrastructure/connectors/ConnectorCommandLayer.ts @@ -311,7 +311,10 @@ export class ConnectorCommandLayer { chatIdArg?: string, ): Promise { if (!(await this.isAdminSender(connectorId, senderId))) { - return { kind: 'allow-group', response: '🔒 此命令仅管理员可用。' }; + const hint = senderId + ? `你的 open_id: ${senderId},请在 Hub 权限管理中添加此 ID。` + : '无法识别发送者(请从群聊中发送此命令)。'; + return { kind: 'allow-group', response: `🔒 此命令仅管理员可用。${hint}` }; } const store = this.deps.permissionStore; if (!store) { @@ -333,7 +336,10 @@ export class ConnectorCommandLayer { chatIdArg?: string, ): Promise { if (!(await this.isAdminSender(connectorId, senderId))) { - return { kind: 'deny-group', response: '🔒 此命令仅管理员可用。' }; + const hint = senderId + ? `你的 open_id: ${senderId},请在 Hub 权限管理中添加此 ID。` + : '无法识别发送者(请从群聊中发送此命令)。'; + return { kind: 'deny-group', response: `🔒 此命令仅管理员可用。${hint}` }; } const store = this.deps.permissionStore; if (!store) { diff --git a/packages/api/src/routes/connector-hub.ts b/packages/api/src/routes/connector-hub.ts index f1e864c17..60409a0a4 100644 --- a/packages/api/src/routes/connector-hub.ts +++ b/packages/api/src/routes/connector-hub.ts @@ -1,7 +1,10 @@ +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { resolve } from 'node:path'; import type { FastifyPluginAsync, FastifyReply, FastifyRequest } from 'fastify'; import { DEFAULT_THREAD_ID, type IThreadStore } from '../domains/cats/services/stores/ports/ThreadStore.js'; import type { WeixinAdapter } from '../infrastructure/connectors/adapters/WeixinAdapter.js'; import type { IConnectorPermissionStore } from '../infrastructure/connectors/ConnectorPermissionStore.js'; +import { resolveActiveProjectRoot } from '../utils/active-project-root.js'; import { resolveHeaderUserId } from '../utils/request-identity.js'; export interface ConnectorHubRoutesOptions { @@ -16,6 +19,10 @@ export interface ConnectorHubRoutesOptions { startWeixinPolling?: () => void; /** F134 Phase D: Permission store for group whitelist + admin management */ permissionStore?: IConnectorPermissionStore | null; + /** Optional override for writing connector env updates in tests */ + envFilePath?: string; + /** Optional fetch override for Feishu registration API in tests */ + feishuRegistrationFetch?: typeof fetch; } function requireTrustedHubIdentity(request: FastifyRequest, reply: FastifyReply): string | null { @@ -57,6 +64,11 @@ interface PlatformDef { steps: PlatformStepDef[]; } +const FEISHU_ACCOUNTS_BASE_URL = 'https://accounts.feishu.cn'; +const LARK_ACCOUNTS_BASE_URL = 'https://accounts.larksuite.com'; + +type FeishuRegistrationResponse = Record; + export const CONNECTOR_PLATFORMS: PlatformDef[] = [ { id: 'feishu', @@ -169,6 +181,86 @@ function maskSensitiveValue(_value: string): string { return '••••••••'; } +function formatEnvFileValue(value: string): string { + const escapedControlChars = value.replace(/\r/g, '\\r').replace(/\n/g, '\\n'); + if (/^[A-Za-z0-9_./:@-]+$/.test(escapedControlChars)) return escapedControlChars; + return `"${escapedControlChars + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\$/g, '\\$') + .replace(/`/g, '\\`')}"`; +} + +function applyEnvUpdatesToFile(contents: string, updates: Map): string { + const lines = contents === '' ? [] : contents.split(/\r?\n/); + const seen = new Set(); + const nextLines: string[] = []; + + for (const line of lines) { + const match = line.match(/^\s*([A-Za-z_][A-Za-z0-9_]*)\s*=/); + if (!match) { + nextLines.push(line); + continue; + } + const name = match[1]!; + if (!updates.has(name)) { + nextLines.push(line); + continue; + } + seen.add(name); + const value = updates.get(name); + if (value == null || value === '') continue; + nextLines.push(`${name}=${formatEnvFileValue(value)}`); + } + + for (const [name, value] of updates) { + if (seen.has(name) || value == null || value === '') continue; + nextLines.push(`${name}=${formatEnvFileValue(value)}`); + } + + const normalized = nextLines + .join('\n') + .replace(/\n{3,}/g, '\n\n') + .trimEnd(); + return normalized.length > 0 ? `${normalized}\n` : ''; +} + +function persistEnvUpdates(envFilePath: string, updates: Map): void { + const current = existsSync(envFilePath) ? readFileSync(envFilePath, 'utf8') : ''; + const next = applyEnvUpdatesToFile(current, updates); + writeFileSync(envFilePath, next, 'utf8'); + for (const [name, value] of updates) { + if (value == null || value === '') delete process.env[name]; + else process.env[name] = value; + } +} + +async function postFeishuRegistration( + fetchFn: typeof fetch, + baseUrl: string, + form: URLSearchParams, +): Promise { + const res = await fetchFn(`${baseUrl}/oauth/v1/app/registration`, { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: form.toString(), + }); + const data = (await res.json().catch(() => ({}))) as FeishuRegistrationResponse; + if (!res.ok && !('error' in data)) { + throw new Error(`registration api ${res.status}`); + } + return data; +} + +function toPositiveNumber(value: unknown, fallback: number): number { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) return value; + if (typeof value === 'string') { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return fallback; +} + export interface PlatformFieldStatus { envName: string; label: string; @@ -237,6 +329,8 @@ export function buildConnectorStatus(env: Record = p export const connectorHubRoutes: FastifyPluginAsync = async (app, opts) => { const { threadStore } = opts; + const envFilePath = opts.envFilePath ?? resolve(resolveActiveProjectRoot(), '.env'); + const feishuRegistrationFetch = opts.feishuRegistrationFetch ?? globalThis.fetch; app.get('/api/connector/hub-threads', async (request, reply) => { const userId = requireTrustedHubIdentity(request, reply); @@ -274,6 +368,127 @@ export const connectorHubRoutes: FastifyPluginAsync = return { platforms: status }; }); + // ── Feishu QR code create/bind routes ── + + app.post('/api/connector/feishu/qrcode', async (request, reply) => { + const userId = requireTrustedHubIdentity(request, reply); + if (!userId) return { error: 'Identity required' }; + + try { + const initData = await postFeishuRegistration( + feishuRegistrationFetch, + FEISHU_ACCOUNTS_BASE_URL, + new URLSearchParams({ action: 'init' }), + ); + const supportedMethods = Array.isArray(initData.supported_auth_methods) ? initData.supported_auth_methods : []; + if (!supportedMethods.includes('client_secret')) { + reply.status(502); + return { error: 'Feishu registration endpoint does not support client_secret auth method' }; + } + + const beginData = await postFeishuRegistration( + feishuRegistrationFetch, + FEISHU_ACCOUNTS_BASE_URL, + new URLSearchParams({ + action: 'begin', + archetype: 'PersonalAgent', + auth_method: 'client_secret', + request_user_info: 'open_id', + }), + ); + + const verificationUri = beginData.verification_uri_complete; + const deviceCode = beginData.device_code; + if (typeof verificationUri !== 'string' || typeof deviceCode !== 'string') { + reply.status(502); + return { error: 'Feishu registration response is missing QR payload' }; + } + + const qrUrl = new URL(verificationUri); + qrUrl.searchParams.set('from', 'onboard'); + + const QRCode = await import('qrcode'); + const qrDataUri = await QRCode.toDataURL(qrUrl.toString(), { width: 384, margin: 2 }); + + return { + qrUrl: qrDataUri, + qrPayload: deviceCode, + interval: toPositiveNumber(beginData.interval, 5), + expiresIn: toPositiveNumber(beginData.expire_in, 600), + }; + } catch (err) { + app.log.error({ err }, '[Feishu QR] Failed to fetch QR code'); + reply.status(502); + return { error: 'Failed to fetch QR code from Feishu registration service' }; + } + }); + + app.get('/api/connector/feishu/qrcode-status', async (request, reply) => { + const userId = requireTrustedHubIdentity(request, reply); + if (!userId) return { error: 'Identity required' }; + + const { qrPayload } = request.query as { qrPayload?: string }; + if (!qrPayload) { + reply.status(400); + return { error: 'qrPayload query parameter required' }; + } + + try { + const pollForm = new URLSearchParams({ action: 'poll', device_code: qrPayload }); + let pollData = await postFeishuRegistration(feishuRegistrationFetch, FEISHU_ACCOUNTS_BASE_URL, pollForm); + + const tenantBrand = ((pollData.user_info as Record | undefined)?.tenant_brand ?? '') as string; + const hasCredentials = typeof pollData.client_id === 'string' && typeof pollData.client_secret === 'string'; + if (!hasCredentials && tenantBrand === 'lark') { + try { + pollData = await postFeishuRegistration(feishuRegistrationFetch, LARK_ACCOUNTS_BASE_URL, pollForm); + } catch (err) { + app.log.warn({ err }, '[Feishu QR] Lark poll fallback failed'); + } + } + + const clientId = pollData.client_id; + const clientSecret = pollData.client_secret; + if (typeof clientId === 'string' && typeof clientSecret === 'string') { + const updates = new Map([ + ['FEISHU_APP_ID', clientId], + ['FEISHU_APP_SECRET', clientSecret], + ]); + const currentMode = process.env.FEISHU_CONNECTION_MODE === 'websocket' ? 'websocket' : 'webhook'; + const verificationToken = process.env.FEISHU_VERIFICATION_TOKEN; + if (currentMode === 'webhook' && (!verificationToken || verificationToken.trim() === '')) { + // QR onboarding does not return webhook verification token; default to websocket so setup is immediately valid. + updates.set('FEISHU_CONNECTION_MODE', 'websocket'); + } + persistEnvUpdates(envFilePath, updates); + app.log.info('[Feishu QR] Bot credentials captured and persisted to env file'); + return { status: 'confirmed' }; + } + + const errorCode = pollData.error; + if (errorCode === 'authorization_pending' || errorCode === 'slow_down') { + return { status: 'waiting' }; + } + if (errorCode === 'access_denied') { + return { status: 'denied' }; + } + if (errorCode === 'expired_token') { + return { status: 'expired' }; + } + if (typeof errorCode === 'string') { + return { + status: 'error', + error: typeof pollData.error_description === 'string' ? pollData.error_description : errorCode, + }; + } + return { status: 'waiting' }; + } catch (err) { + app.log.error({ err }, '[Feishu QR] Failed to poll QR status'); + reply.status(502); + return { error: 'Failed to poll Feishu QR status' }; + } + }); + // ── F137: WeChat QR code login routes ── app.post('/api/connector/weixin/qrcode', async (request, reply) => { diff --git a/packages/api/test/connector-hub-route.test.js b/packages/api/test/connector-hub-route.test.js index 5bbc83965..cc9600983 100644 --- a/packages/api/test/connector-hub-route.test.js +++ b/packages/api/test/connector-hub-route.test.js @@ -1,4 +1,7 @@ import assert from 'node:assert/strict'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; import { describe, it } from 'node:test'; import Fastify from 'fastify'; @@ -6,6 +9,13 @@ const { connectorHubRoutes } = await import('../dist/routes/connector-hub.js'); const AUTH_HEADERS = { 'x-cat-cafe-user': 'owner-1' }; +function jsonResponse(body, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + async function buildApp(overrides = {}) { const listCalls = []; const threadStore = { @@ -194,6 +204,118 @@ describe('POST /api/connector/weixin/disconnect', () => { }); }); +describe('Feishu QR routes', () => { + it('POST /api/connector/feishu/qrcode returns QR image and payload', async () => { + const app = Fastify(); + await app.register(connectorHubRoutes, { + threadStore: { + async list() { + return []; + }, + }, + feishuRegistrationFetch: async (_url, init) => { + const form = new URLSearchParams(String(init?.body ?? '')); + const action = form.get('action'); + if (action === 'init') { + return jsonResponse({ supported_auth_methods: ['client_secret'] }); + } + if (action === 'begin') { + return jsonResponse({ + verification_uri_complete: 'https://accounts.feishu.cn/oauth/verify?token=abc', + device_code: 'device-abc', + interval: 5, + expire_in: 600, + }); + } + return jsonResponse({ error: 'unexpected_action' }, 400); + }, + }); + await app.ready(); + + const res = await app.inject({ + method: 'POST', + url: '/api/connector/feishu/qrcode', + headers: AUTH_HEADERS, + }); + + assert.equal(res.statusCode, 200); + const body = JSON.parse(res.body); + assert.equal(body.qrPayload, 'device-abc'); + assert.ok(typeof body.qrUrl === 'string' && body.qrUrl.startsWith('data:image/png;base64,')); + assert.equal(body.interval, 5); + assert.equal(body.expiresIn, 600); + + await app.close(); + }); + + it('GET /api/connector/feishu/qrcode-status persists credentials to env file on confirm', async () => { + const tempRoot = mkdtempSync(join(tmpdir(), 'cat-cafe-feishu-qr-')); + const envFilePath = join(tempRoot, '.env'); + writeFileSync(envFilePath, '', 'utf8'); + + const originalEnv = { + FEISHU_APP_ID: process.env.FEISHU_APP_ID, + FEISHU_APP_SECRET: process.env.FEISHU_APP_SECRET, + FEISHU_CONNECTION_MODE: process.env.FEISHU_CONNECTION_MODE, + FEISHU_VERIFICATION_TOKEN: process.env.FEISHU_VERIFICATION_TOKEN, + }; + + delete process.env.FEISHU_CONNECTION_MODE; + delete process.env.FEISHU_VERIFICATION_TOKEN; + delete process.env.FEISHU_APP_ID; + delete process.env.FEISHU_APP_SECRET; + + const app = Fastify(); + await app.register(connectorHubRoutes, { + threadStore: { + async list() { + return []; + }, + }, + envFilePath, + feishuRegistrationFetch: async (_url, init) => { + const form = new URLSearchParams(String(init?.body ?? '')); + const action = form.get('action'); + if (action === 'poll') { + return jsonResponse({ + client_id: 'cli_test_app_id', + client_secret: 'test_app_secret_123', + user_info: { open_id: 'ou_test', tenant_brand: 'feishu' }, + }); + } + return jsonResponse({ error: 'unexpected_action' }, 400); + }, + }); + await app.ready(); + + try { + const res = await app.inject({ + method: 'GET', + url: '/api/connector/feishu/qrcode-status?qrPayload=device-xyz', + headers: AUTH_HEADERS, + }); + assert.equal(res.statusCode, 200); + assert.equal(JSON.parse(res.body).status, 'confirmed'); + + const envText = readFileSync(envFilePath, 'utf8'); + assert.match(envText, /FEISHU_APP_ID=cli_test_app_id/); + assert.match(envText, /FEISHU_APP_SECRET=test_app_secret_123/); + assert.match(envText, /FEISHU_CONNECTION_MODE=websocket/); + } finally { + if (originalEnv.FEISHU_APP_ID == null) delete process.env.FEISHU_APP_ID; + else process.env.FEISHU_APP_ID = originalEnv.FEISHU_APP_ID; + if (originalEnv.FEISHU_APP_SECRET == null) delete process.env.FEISHU_APP_SECRET; + else process.env.FEISHU_APP_SECRET = originalEnv.FEISHU_APP_SECRET; + if (originalEnv.FEISHU_CONNECTION_MODE == null) delete process.env.FEISHU_CONNECTION_MODE; + else process.env.FEISHU_CONNECTION_MODE = originalEnv.FEISHU_CONNECTION_MODE; + if (originalEnv.FEISHU_VERIFICATION_TOKEN == null) delete process.env.FEISHU_VERIFICATION_TOKEN; + else process.env.FEISHU_VERIFICATION_TOKEN = originalEnv.FEISHU_VERIFICATION_TOKEN; + await app.close(); + rmSync(tempRoot, { recursive: true, force: true }); + } + }); +}); + describe('GET /api/connector/hub-threads', () => { it('returns 401 when only a spoofed userId query param is provided', async () => { const { app } = await buildApp(); diff --git a/packages/web/src/components/FeishuQrPanel.tsx b/packages/web/src/components/FeishuQrPanel.tsx new file mode 100644 index 000000000..e168cfc8d --- /dev/null +++ b/packages/web/src/components/FeishuQrPanel.tsx @@ -0,0 +1,177 @@ +'use client'; + +import { useCallback, useEffect, useRef, useState } from 'react'; +import { apiFetch } from '@/utils/api-client'; +import { CheckCircleIcon, QrCodeIcon, SpinnerIcon } from './HubConfigIcons'; + +type QrState = 'idle' | 'fetching' | 'waiting' | 'confirmed' | 'error' | 'expired' | 'denied'; + +const DEFAULT_POLL_INTERVAL_MS = 2500; +const DEFAULT_EXPIRE_MS = 10 * 60_000; + +interface FeishuQrPanelProps { + configured: boolean; + onConfirmed?: () => void; +} + +export function FeishuQrPanel({ configured, onConfirmed }: FeishuQrPanelProps) { + const [qrState, setQrState] = useState(configured ? 'confirmed' : 'idle'); + const [qrUrl, setQrUrl] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + const pollRef = useRef | null>(null); + const expireRef = useRef | null>(null); + const terminalRef = useRef(false); + const requestSeqRef = useRef(0); + + const stopPolling = useCallback((markTerminal = false) => { + if (markTerminal) { + terminalRef.current = true; + } + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + if (expireRef.current) { + clearTimeout(expireRef.current); + expireRef.current = null; + } + }, []); + + useEffect(() => () => stopPolling(true), [stopPolling]); + + const startPolling = useCallback( + (payload: string, intervalMs: number, expireMs: number) => { + stopPolling(); + terminalRef.current = false; + requestSeqRef.current = 0; + + const poll = async () => { + const requestSeq = ++requestSeqRef.current; + try { + const res = await apiFetch(`/api/connector/feishu/qrcode-status?qrPayload=${encodeURIComponent(payload)}`); + if (!res.ok) return; + const data = await res.json(); + if (terminalRef.current || requestSeq !== requestSeqRef.current) return; + + if (data.status === 'confirmed') { + stopPolling(true); + setQrState('confirmed'); + setQrUrl(null); + onConfirmed?.(); + } else if (data.status === 'expired') { + stopPolling(true); + setQrState('expired'); + setQrUrl(null); + } else if (data.status === 'denied') { + stopPolling(true); + setQrState('denied'); + setQrUrl(null); + } else if (data.status === 'error') { + stopPolling(true); + setQrState('error'); + setQrUrl(null); + setErrorMsg(data.error ?? 'Failed to complete QR binding'); + } else { + setQrState('waiting'); + } + } catch { + /* network hiccup — keep polling */ + } + }; + + pollRef.current = setInterval(poll, intervalMs); + poll(); + + expireRef.current = setTimeout(() => { + stopPolling(true); + setQrState('expired'); + setQrUrl(null); + }, expireMs); + }, + [onConfirmed, stopPolling], + ); + + const handleFetchQr = async () => { + setQrState('fetching'); + setErrorMsg(null); + + try { + const res = await apiFetch('/api/connector/feishu/qrcode', { method: 'POST' }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + setQrState('error'); + setErrorMsg(data.error ?? 'Failed to fetch Feishu QR code'); + return; + } + + const data = await res.json(); + const interval = typeof data.interval === 'number' && data.interval > 0 ? data.interval * 1000 : 2500; + const expiresIn = typeof data.expiresIn === 'number' && data.expiresIn > 0 ? data.expiresIn * 1000 : 600_000; + setQrUrl(data.qrUrl ?? null); + setQrState('waiting'); + startPolling(data.qrPayload, interval || DEFAULT_POLL_INTERVAL_MS, expiresIn || DEFAULT_EXPIRE_MS); + } catch { + setQrState('error'); + setErrorMsg('Network error'); + } + }; + + if (qrState === 'confirmed') { + return ( +
+ + + + Feishu bot bound (restart API to take effect) +
+ ); + } + + return ( +
+ {(qrState === 'idle' || qrState === 'expired' || qrState === 'error' || qrState === 'denied') && ( +
+ {qrState === 'expired' && ( +

QR code expired. Please generate a new one.

+ )} + {qrState === 'denied' &&

Authorization denied. Please try again.

} + {qrState === 'error' && errorMsg &&

{errorMsg}

} + +
+ )} + + {qrState === 'fetching' && ( +
+ + Generating QR code... +
+ )} + + {qrState === 'waiting' && qrUrl && ( +
+ Feishu bot binding QR code +
+ + Scan the QR code with Feishu +
+
+ )} +
+ ); +} diff --git a/packages/web/src/components/HubConnectorConfigTab.tsx b/packages/web/src/components/HubConnectorConfigTab.tsx index 3de1f8987..c61f6619b 100644 --- a/packages/web/src/components/HubConnectorConfigTab.tsx +++ b/packages/web/src/components/HubConnectorConfigTab.tsx @@ -2,6 +2,7 @@ import { useCallback, useEffect, useState } from 'react'; import { apiFetch } from '@/utils/api-client'; +import { FeishuQrPanel } from './FeishuQrPanel'; import { ChevronDown, ChevronRight, @@ -83,7 +84,14 @@ export function HubConnectorConfigTab() { .map((f) => ({ name: f.envName, value: fieldValues[f.envName] || null })); if (updates.length === 0) { - setSaveResult({ type: 'error', message: '请填写至少一个配置项' }); + if (platform.configured) { + setSaveResult({ + type: 'success', + message: '当前无可保存的配置变更。若已通过扫码完成绑定,凭证已写入配置,无需再次保存。', + }); + } else { + setSaveResult({ type: 'error', message: '请填写至少一个配置项' }); + } return; } @@ -130,6 +138,10 @@ export function HubConnectorConfigTab() { : undefined; const filteredSteps = platform.steps.filter((s) => !s.mode || s.mode === selectedMode); const guideSteps = filteredSteps.slice(0, -1); + const showFeishuQr = platform.id === 'feishu'; + const qrStepNum = guideSteps.length + 1; + const credentialStepNum = guideSteps.length + (showFeishuQr ? 2 : 1); + const verifyStepNum = credentialStepNum + 1; return (
))} + {showFeishuQr && ( +
+
+ + 扫码创建并绑定飞书机器人(推荐) +
+
+ +
+
+ )} +
- + 填写应用凭证
@@ -256,7 +280,7 @@ export function HubConnectorConfigTab() {
- + 测试连接并保存
{saveResult && ( diff --git a/packages/web/src/components/__tests__/feishu-qr-panel.test.tsx b/packages/web/src/components/__tests__/feishu-qr-panel.test.tsx new file mode 100644 index 000000000..d50b7c183 --- /dev/null +++ b/packages/web/src/components/__tests__/feishu-qr-panel.test.tsx @@ -0,0 +1,195 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/api-client', () => ({ apiFetch: vi.fn() })); + +import { apiFetch } from '@/utils/api-client'; + +const mockApiFetch = vi.mocked(apiFetch); + +const { FeishuQrPanel } = await import('../FeishuQrPanel'); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + }); +} + +function queryTestId(el: HTMLElement, testId: string): HTMLElement | null { + return el.querySelector(`[data-testid="${testId}"]`); +} + +function queryButton(el: HTMLElement, text: string): HTMLButtonElement { + const btn = Array.from(el.querySelectorAll('button')).find((b) => b.textContent?.includes(text)); + if (!btn) throw new Error(`Missing button: ${text}`); + return btn as HTMLButtonElement; +} + +describe('FeishuQrPanel', () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + (globalThis as { React?: typeof React }).React = React; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterAll(() => { + delete (globalThis as { React?: typeof React }).React; + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + beforeEach(() => { + vi.useFakeTimers({ shouldAdvanceTime: true }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mockApiFetch.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.useRealTimers(); + vi.clearAllMocks(); + }); + + it('shows connected state when already configured', async () => { + await act(async () => { + root.render(React.createElement(FeishuQrPanel, { configured: true })); + }); + await flushEffects(); + + expect(queryTestId(container, 'feishu-connected')).not.toBeNull(); + expect(container.textContent).toContain('Feishu bot bound'); + }); + + it('fetches QR code on button click and displays image', async () => { + mockApiFetch.mockResolvedValueOnce( + jsonResponse({ qrUrl: 'https://example.com/feishu-qr.png', qrPayload: 'devcode', interval: 1, expiresIn: 60 }), + ); + mockApiFetch.mockResolvedValue(jsonResponse({ status: 'waiting' })); + + await act(async () => { + root.render(React.createElement(FeishuQrPanel, { configured: false })); + }); + await flushEffects(); + + await act(async () => { + queryButton(container, 'Generate QR Code').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + const img = queryTestId(container, 'feishu-qr-image') as HTMLImageElement | null; + expect(img).not.toBeNull(); + expect(img!.src).toBe('https://example.com/feishu-qr.png'); + }); + + it('transitions to confirmed after polling confirmed status', async () => { + const onConfirmed = vi.fn(); + mockApiFetch + .mockResolvedValueOnce( + jsonResponse({ qrUrl: 'https://example.com/feishu-qr.png', qrPayload: 'devcode', interval: 1, expiresIn: 60 }), + ) + .mockResolvedValueOnce(jsonResponse({ status: 'waiting' })) + .mockResolvedValueOnce(jsonResponse({ status: 'confirmed' })); + + await act(async () => { + root.render(React.createElement(FeishuQrPanel, { configured: false, onConfirmed })); + }); + await flushEffects(); + + await act(async () => { + queryButton(container, 'Generate QR Code').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + await act(async () => { + vi.advanceTimersByTime(1200); + }); + await flushEffects(); + + expect(queryTestId(container, 'feishu-connected')).not.toBeNull(); + expect(onConfirmed).toHaveBeenCalledTimes(1); + }); + + it('shows denied state when authorization is denied', async () => { + mockApiFetch + .mockResolvedValueOnce( + jsonResponse({ qrUrl: 'https://example.com/feishu-qr.png', qrPayload: 'devcode', interval: 1, expiresIn: 60 }), + ) + .mockResolvedValueOnce(jsonResponse({ status: 'denied' })); + + await act(async () => { + root.render(React.createElement(FeishuQrPanel, { configured: false })); + }); + await flushEffects(); + + await act(async () => { + queryButton(container, 'Generate QR Code').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + expect(container.textContent).toContain('Authorization denied'); + expect(container.textContent).toContain('Regenerate QR Code'); + }); + + it('ignores stale poll responses after confirmed status', async () => { + let resolveFirstPoll: ((value: Response) => void) | null = null; + let pollCount = 0; + mockApiFetch.mockImplementation(async (path: string) => { + if (path === '/api/connector/feishu/qrcode') { + return jsonResponse({ + qrUrl: 'https://example.com/feishu-qr.png', + qrPayload: 'devcode', + interval: 1, + expiresIn: 60, + }); + } + if (path.startsWith('/api/connector/feishu/qrcode-status')) { + pollCount += 1; + if (pollCount === 1) { + return await new Promise((resolve) => { + resolveFirstPoll = resolve; + }); + } + if (pollCount === 2) { + return jsonResponse({ status: 'confirmed' }); + } + } + return jsonResponse({ status: 'waiting' }); + }); + + await act(async () => { + root.render(React.createElement(FeishuQrPanel, { configured: false })); + }); + await flushEffects(); + + await act(async () => { + queryButton(container, 'Generate QR Code').dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + await act(async () => { + vi.advanceTimersByTime(1200); + }); + await flushEffects(); + + expect(queryTestId(container, 'feishu-connected')).not.toBeNull(); + + await act(async () => { + resolveFirstPoll?.(jsonResponse({ status: 'waiting' })); + }); + await flushEffects(); + + expect(queryTestId(container, 'feishu-connected')).not.toBeNull(); + }); +}); diff --git a/packages/web/src/components/__tests__/hub-connector-config-tab.test.tsx b/packages/web/src/components/__tests__/hub-connector-config-tab.test.tsx new file mode 100644 index 000000000..f15e779fe --- /dev/null +++ b/packages/web/src/components/__tests__/hub-connector-config-tab.test.tsx @@ -0,0 +1,164 @@ +import React, { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@/utils/api-client', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/FeishuQrPanel', () => ({ + FeishuQrPanel: () => React.createElement('div', { 'data-testid': 'feishu-qr-panel-mock' }, 'Feishu QR Mock'), +})); + +import { apiFetch } from '@/utils/api-client'; + +const mockApiFetch = vi.mocked(apiFetch); + +const { HubConnectorConfigTab } = await import('../HubConnectorConfigTab'); + +function jsonResponse(body: unknown, status = 200): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'content-type': 'application/json' }, + }); +} + +async function flushEffects() { + await act(async () => { + await Promise.resolve(); + }); +} + +describe('HubConnectorConfigTab', () => { + let container: HTMLDivElement; + let root: Root; + + beforeAll(() => { + (globalThis as { React?: typeof React }).React = React; + (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true; + }); + + afterAll(() => { + delete (globalThis as { React?: typeof React }).React; + delete (globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT; + }); + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + mockApiFetch.mockReset(); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + it('shows success hint instead of error when configured connector has no editable changes', async () => { + mockApiFetch.mockResolvedValueOnce( + jsonResponse({ + platforms: [ + { + id: 'feishu', + name: '飞书', + nameEn: 'Feishu', + configured: true, + docsUrl: 'https://open.feishu.cn', + steps: [{ text: '配置文档' }, { text: '完成验证' }], + fields: [ + { envName: 'FEISHU_APP_ID', label: 'App ID', sensitive: true, currentValue: 'cli_xxx' }, + { envName: 'FEISHU_APP_SECRET', label: 'App Secret', sensitive: true, currentValue: 'sec_xxx' }, + { + envName: 'FEISHU_CONNECTION_MODE', + label: '连接模式', + sensitive: false, + currentValue: 'websocket', + }, + ], + }, + ], + }), + ); + + await act(async () => { + root.render(React.createElement(HubConnectorConfigTab)); + }); + await flushEffects(); + + const card = container.querySelector('[data-testid="platform-card-feishu"]'); + expect(card).toBeTruthy(); + + const expandBtn = card?.querySelector('button'); + await act(async () => { + expandBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + const saveBtn = container.querySelector('[data-testid="save-feishu"]') as HTMLButtonElement | null; + expect(saveBtn).toBeTruthy(); + + await act(async () => { + saveBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + const result = container.querySelector('[data-testid="save-result"]'); + expect(result?.textContent).toContain('当前无可保存的配置变更'); + expect(result?.textContent).toContain('无需再次保存'); + expect(result?.className).toContain('bg-green-50'); + + const secretCalls = mockApiFetch.mock.calls.filter( + ([path, init]) => path === '/api/config/secrets' && init?.method === 'POST', + ); + expect(secretCalls).toHaveLength(0); + }); + + it('keeps error hint when connector is not configured and no field was edited', async () => { + mockApiFetch.mockResolvedValueOnce( + jsonResponse({ + platforms: [ + { + id: 'feishu', + name: '飞书', + nameEn: 'Feishu', + configured: false, + docsUrl: 'https://open.feishu.cn', + steps: [{ text: '配置文档' }, { text: '完成验证' }], + fields: [ + // Simulate partially configured credentials from manual edits/legacy state. + { envName: 'FEISHU_APP_ID', label: 'App ID', sensitive: true, currentValue: 'cli_partial' }, + { envName: 'FEISHU_APP_SECRET', label: 'App Secret', sensitive: true, currentValue: 'sec_partial' }, + { + envName: 'FEISHU_CONNECTION_MODE', + label: '连接模式', + sensitive: false, + currentValue: 'websocket', + }, + ], + }, + ], + }), + ); + + await act(async () => { + root.render(React.createElement(HubConnectorConfigTab)); + }); + await flushEffects(); + + const card = container.querySelector('[data-testid="platform-card-feishu"]'); + const expandBtn = card?.querySelector('button'); + await act(async () => { + expandBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + const saveBtn = container.querySelector('[data-testid="save-feishu"]') as HTMLButtonElement | null; + await act(async () => { + saveBtn?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + }); + await flushEffects(); + + const result = container.querySelector('[data-testid="save-result"]'); + expect(result?.textContent).toContain('请填写至少一个配置项'); + expect(result?.className).toContain('bg-red-50'); + }); +});