mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
feat(hermes): Batch 3 §Q - OAuth 三种登录 (PKCE / device_code / external)
校对发现端点 + token 注入已就位(前一 commit),可直接做 UI。
## 新页面 /h/oauth (~250 行)
### 数据流
- GET /api/providers/oauth - 列表 + 状态(公开)
- POST /api/providers/oauth/{id}/start - PKCE 返回 auth_url,device_code 返回 user_code + verification_url
- POST /api/providers/oauth/{id}/submit { session_id, code } - PKCE 提交
- GET /api/providers/oauth/{id}/poll/{session_id} - device_code 轮询(公开)
- DELETE /api/providers/oauth/{id} - 断开
### UI
- 卡片网格(复用 .lazy-deps-grid)展示每个 provider
- active 徽章 + flow 标签 + token preview + expires 信息
- 「登录」按钮按 flow 类型分发:
· **external** (claude-code 等):showContentModal 显示 CLI 命令,用户终端运行后回点刷新
· **PKCE** (anthropic):
1. 系统 window.open(auth_url) 打开浏览器授权
2. showModal 弹窗:显示授权链接 + 让用户粘贴回调 code
3. POST submit → 刷新状态
· **device_code** (qwen / minimax / nous / openai):
1. window.open(verification_url) 打开授权页
2. showContentModal 显示 6 位 user_code(大字号 mono 字体)
3. 每 2.5s 自动 GET poll 轮询直到 success/failed/expired
4. 10 分钟超时
5. MutationObserver 监听模态关闭自动停轮询
### sidebar
- 管理 section 加 OAuth 入口(memory icon)
- 路由 /h/oauth 注册
### 修复:之前 edit 弄坏的 sidebar 结构
- 恢复 monitor 5 项(dashboard/chat/sessions/logs/usage)
- 恢复 manage 8 项(skills/memory/cron/profiles/kanban/oauth/lazy-deps/extensions)
- 删除文件末尾多余的 `}`
### i18n
- 26 个新键 × 3 语言(hermesOAuth*)
## 复用基础设施
- hermes_dashboard_api_proxy 自动注入 session token(前一 commit 已做)
- humanizeError 友好错误
- showModal / showContentModal / toast / lazy-deps-grid 样式
## 累计
- 1 个新页面 ~250 行
- sidebar 结构修复 + /h/oauth 路由注册
- 26 个 i18n 键 × 3 语言
- npm build ✓
This commit is contained in:
@@ -86,7 +86,11 @@ export default {
|
||||
{ route: '/h/skills', label: t('sidebar.skills'), icon: 'skills' },
|
||||
{ route: '/h/memory', label: t('sidebar.memory'), icon: 'memory' },
|
||||
{ route: '/h/cron', label: t('sidebar.cron'), icon: 'clock' },
|
||||
{ route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' },
|
||||
{ route: '/h/profiles', label: t('engine.hermesProfilesTitle'), icon: 'agents' },
|
||||
{ route: '/h/kanban', label: t('engine.hermesKanbanTitle'), icon: 'inbox' },
|
||||
{ route: '/h/oauth', label: t('engine.hermesOAuthTitle'), icon: 'memory' },
|
||||
{ route: '/h/lazy-deps', label: t('hermesLazyDeps.title'), icon: 'package' },
|
||||
{ route: '/h/extensions', label: t('sidebar.extensions'), icon: 'package' },
|
||||
]
|
||||
}, {
|
||||
section: '',
|
||||
|
||||
287
src/engines/hermes/pages/oauth.js
Normal file
287
src/engines/hermes/pages/oauth.js
Normal file
@@ -0,0 +1,287 @@
|
||||
/**
|
||||
* Hermes OAuth 三种登录(Batch 3 §Q)
|
||||
*
|
||||
* 全部走 dashboard 9119(hermes_dashboard_api_proxy 自动注入 session token):
|
||||
* - GET /api/providers/oauth - 列表 + 状态
|
||||
* - POST /api/providers/oauth/{id}/start - 启动
|
||||
* · PKCE: 返回 { session_id, flow:"pkce", auth_url }
|
||||
* · device_code: 返回 { session_id, flow:"device_code", user_code, verification_url }
|
||||
* - POST /api/providers/oauth/{id}/submit { session_id, code } - PKCE 提交回调
|
||||
* - GET /api/providers/oauth/{id}/poll/{session_id} - 公开轮询(device_code)
|
||||
* - DELETE /api/providers/oauth/{id} - 断开
|
||||
*/
|
||||
import { t } from '../../../lib/i18n.js'
|
||||
import { api } from '../../../lib/tauri-api.js'
|
||||
import { toast } from '../../../components/toast.js'
|
||||
import { showModal, showContentModal } from '../../../components/modal.js'
|
||||
import { humanizeError } from '../../../lib/humanize-error.js'
|
||||
|
||||
const OAUTH_BASE = '/api/providers/oauth'
|
||||
|
||||
function escHtml(s) {
|
||||
return String(s ?? '').replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"')
|
||||
}
|
||||
function escAttr(s) { return escHtml(s) }
|
||||
|
||||
export function render() {
|
||||
const el = document.createElement('div')
|
||||
el.className = 'page'
|
||||
el.dataset.engine = 'hermes'
|
||||
|
||||
let providers = []
|
||||
let loading = true
|
||||
let error = ''
|
||||
|
||||
function draw() {
|
||||
el.innerHTML = `
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">${escHtml(t('engine.hermesOAuthTitle'))}</h1>
|
||||
<p class="page-desc">${escHtml(t('engine.hermesOAuthDesc'))}</p>
|
||||
</div>
|
||||
<div class="config-actions">
|
||||
<button class="btn btn-secondary btn-sm" id="hm-oauth-refresh">${escHtml(t('hermesLazyDeps.refresh'))}</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="hm-oauth-content">
|
||||
${loading ? `<div style="padding:32px;text-align:center;color:var(--text-tertiary)">${escHtml(t('common.loading'))}…</div>` : ''}
|
||||
${error ? `<div style="color:var(--error);padding:20px">${escHtml(error)}</div>` : ''}
|
||||
${(!loading && !error && !providers.length) ? `
|
||||
<div class="empty-state empty-compact">
|
||||
<div class="empty-icon">🔐</div>
|
||||
<div class="empty-title">${escHtml(t('engine.hermesOAuthEmpty'))}</div>
|
||||
</div>` : ''}
|
||||
${(!loading && providers.length) ? `
|
||||
<div class="lazy-deps-grid">
|
||||
${providers.map(renderProviderCard).join('')}
|
||||
</div>` : ''}
|
||||
</div>
|
||||
`
|
||||
bind()
|
||||
}
|
||||
|
||||
function renderProviderCard(p) {
|
||||
const loggedIn = !!p.status?.logged_in
|
||||
const flowLabel = {
|
||||
pkce: t('engine.hermesOAuthFlowPkce'),
|
||||
device_code: t('engine.hermesOAuthFlowDevice'),
|
||||
external: t('engine.hermesOAuthFlowExternal'),
|
||||
}[p.flow] || p.flow
|
||||
const sourceLabel = p.status?.source_label || ''
|
||||
const tokenPrev = p.status?.token_preview || ''
|
||||
const expires = p.status?.expires_at || ''
|
||||
return `
|
||||
<div class="lazy-deps-card">
|
||||
<div class="lazy-deps-card-head">
|
||||
<div class="lazy-deps-card-title" title="${escAttr(p.id)}">${escHtml(p.name)}</div>
|
||||
${loggedIn ? `<span class="lazy-deps-badge ok">${escHtml(t('engine.hermesOAuthConnected'))}</span>` : `<span class="lazy-deps-badge">${escHtml(t('engine.hermesOAuthDisconnected'))}</span>`}
|
||||
</div>
|
||||
<div class="lazy-deps-card-meta">${escHtml(flowLabel)}</div>
|
||||
${loggedIn && sourceLabel ? `<div class="lazy-deps-card-meta" title="${escAttr(sourceLabel)}">${escHtml(sourceLabel)}</div>` : ''}
|
||||
${loggedIn && tokenPrev ? `<div class="lazy-deps-card-meta" style="font-family:var(--font-mono);font-size:11px">…${escHtml(tokenPrev)}</div>` : ''}
|
||||
${loggedIn && expires ? `<div class="lazy-deps-card-meta">${escHtml(t('engine.hermesOAuthExpires'))}: ${escHtml(expires)}</div>` : ''}
|
||||
<div class="lazy-deps-card-actions" style="gap:6px">
|
||||
${loggedIn
|
||||
? `<button class="btn btn-secondary btn-sm" data-action="disconnect" data-id="${escAttr(p.id)}" style="color:var(--error)">${escHtml(t('engine.hermesOAuthDisconnect'))}</button>`
|
||||
: `<button class="btn btn-primary btn-sm" data-action="connect" data-id="${escAttr(p.id)}" data-flow="${escAttr(p.flow)}" data-cli="${escAttr(p.cli_command || '')}">${escHtml(t('engine.hermesOAuthConnect'))}</button>`}
|
||||
${p.docs_url ? `<a class="btn btn-secondary btn-sm" href="${escAttr(p.docs_url)}" target="_blank" rel="noopener">${escHtml(t('engine.hermesOAuthDocs'))}</a>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
|
||||
function bind() {
|
||||
el.querySelector('#hm-oauth-refresh')?.addEventListener('click', load)
|
||||
el.querySelectorAll('[data-action]').forEach(btn => {
|
||||
const action = btn.dataset.action
|
||||
btn.addEventListener('click', () => {
|
||||
if (action === 'connect') {
|
||||
onConnect(btn.dataset.id, btn.dataset.flow, btn.dataset.cli || '')
|
||||
} else if (action === 'disconnect') {
|
||||
onDisconnect(btn.dataset.id)
|
||||
}
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
async function load() {
|
||||
loading = true
|
||||
error = ''
|
||||
draw()
|
||||
try {
|
||||
const data = await api.hermesDashboardApi('GET', OAUTH_BASE)
|
||||
providers = data?.providers || []
|
||||
} catch (e) {
|
||||
error = String(e?.message || e)
|
||||
} finally {
|
||||
loading = false
|
||||
draw()
|
||||
}
|
||||
}
|
||||
|
||||
async function onConnect(providerId, flow, cliCommand) {
|
||||
if (flow === 'external') {
|
||||
showContentModal({
|
||||
title: t('engine.hermesOAuthExternalTitle', { id: providerId }),
|
||||
content: `
|
||||
<p>${escHtml(t('engine.hermesOAuthExternalHint'))}</p>
|
||||
<pre style="background:var(--surface-1);padding:12px;border-radius:6px;font-family:var(--font-mono);font-size:12px">${escHtml(cliCommand || 'hermes auth add ' + providerId)}</pre>
|
||||
<p style="color:var(--text-tertiary);font-size:12px;margin-top:12px">${escHtml(t('engine.hermesOAuthExternalRefresh'))}</p>
|
||||
`,
|
||||
buttons: [{ label: t('common.close'), className: 'btn-secondary' }],
|
||||
width: 520,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await api.hermesDashboardApi('POST', `${OAUTH_BASE}/${encodeURIComponent(providerId)}/start`)
|
||||
if (flow === 'pkce') {
|
||||
await runPkceFlow(providerId, resp)
|
||||
} else if (flow === 'device_code') {
|
||||
await runDeviceCodeFlow(providerId, resp)
|
||||
} else {
|
||||
toast(t('engine.hermesOAuthUnknownFlow', { flow }), 'error')
|
||||
}
|
||||
} catch (e) {
|
||||
toast(humanizeError(e, t('engine.hermesOAuthStartFailed')), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
async function runPkceFlow(providerId, resp) {
|
||||
const authUrl = resp?.auth_url
|
||||
const sessionId = resp?.session_id
|
||||
if (!authUrl || !sessionId) {
|
||||
toast(t('engine.hermesOAuthBadResponse'), 'error')
|
||||
return
|
||||
}
|
||||
// 打开浏览器(系统默认)
|
||||
try { window.open(authUrl, '_blank', 'noopener') } catch {}
|
||||
// 显示弹窗让用户填回调 code
|
||||
showModal({
|
||||
title: t('engine.hermesOAuthPkceTitle'),
|
||||
fields: [
|
||||
{
|
||||
name: 'url',
|
||||
label: t('engine.hermesOAuthPkceAuthLink'),
|
||||
value: authUrl,
|
||||
readonly: true,
|
||||
hint: t('engine.hermesOAuthPkceUrlHint'),
|
||||
},
|
||||
{
|
||||
name: 'code',
|
||||
label: t('engine.hermesOAuthPkceCodeLabel'),
|
||||
value: '',
|
||||
placeholder: 'authorization_code',
|
||||
hint: t('engine.hermesOAuthPkceCodeHint'),
|
||||
},
|
||||
],
|
||||
onConfirm: async (data) => {
|
||||
const code = (data.code || '').trim()
|
||||
if (!code) {
|
||||
toast(t('engine.hermesOAuthCodeRequired'), 'error')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.hermesDashboardApi('POST', `${OAUTH_BASE}/${encodeURIComponent(providerId)}/submit`, {
|
||||
session_id: sessionId,
|
||||
code,
|
||||
})
|
||||
toast(t('engine.hermesOAuthConnected'), 'success')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast(humanizeError(e, t('engine.hermesOAuthSubmitFailed')), 'error')
|
||||
}
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function runDeviceCodeFlow(providerId, resp) {
|
||||
const userCode = resp?.user_code
|
||||
const verifUrl = resp?.verification_url
|
||||
const sessionId = resp?.session_id
|
||||
if (!userCode || !verifUrl || !sessionId) {
|
||||
toast(t('engine.hermesOAuthBadResponse'), 'error')
|
||||
return
|
||||
}
|
||||
// 打开浏览器
|
||||
try { window.open(verifUrl, '_blank', 'noopener') } catch {}
|
||||
// 显示 user_code + 自动轮询
|
||||
const modal = showContentModal({
|
||||
title: t('engine.hermesOAuthDeviceTitle'),
|
||||
content: `
|
||||
<p>${escHtml(t('engine.hermesOAuthDeviceHint'))}</p>
|
||||
<div style="text-align:center;margin:16px 0">
|
||||
<div style="font-size:28px;font-weight:600;letter-spacing:4px;font-family:var(--font-mono);background:var(--surface-1);padding:14px 20px;border-radius:8px;display:inline-block">${escHtml(userCode)}</div>
|
||||
</div>
|
||||
<p style="font-size:12px;color:var(--text-tertiary);text-align:center">${escHtml(verifUrl)}</p>
|
||||
<div id="hm-oauth-device-status" style="margin-top:16px;padding:12px;background:var(--surface-1);border-radius:6px;font-size:13px;color:var(--text-secondary);text-align:center">${escHtml(t('engine.hermesOAuthDeviceWaiting'))}</div>
|
||||
`,
|
||||
buttons: [{ label: t('common.close'), className: 'btn-secondary' }],
|
||||
width: 520,
|
||||
})
|
||||
|
||||
// 轮询
|
||||
let stopped = false
|
||||
modal.addEventListener?.('click', (e) => {
|
||||
if (e.target.dataset?.action === 'close' || e.target.closest?.('[data-action="close"]')) stopped = true
|
||||
})
|
||||
// 兜底:modal 移除时停轮询
|
||||
const observer = new MutationObserver(() => {
|
||||
if (!modal.isConnected) {
|
||||
stopped = true
|
||||
observer.disconnect()
|
||||
}
|
||||
})
|
||||
if (modal.parentNode) observer.observe(modal.parentNode, { childList: true })
|
||||
|
||||
const startTime = Date.now()
|
||||
const TIMEOUT_MS = 10 * 60 * 1000 // 10 min
|
||||
while (!stopped) {
|
||||
await new Promise(r => setTimeout(r, 2500))
|
||||
if (stopped) break
|
||||
if (Date.now() - startTime > TIMEOUT_MS) {
|
||||
const statusEl = modal.querySelector?.('#hm-oauth-device-status')
|
||||
if (statusEl) statusEl.innerHTML = `<span style="color:var(--error)">${escHtml(t('engine.hermesOAuthDeviceTimeout'))}</span>`
|
||||
break
|
||||
}
|
||||
try {
|
||||
const st = await api.hermesDashboardApi('GET', `${OAUTH_BASE}/${encodeURIComponent(providerId)}/poll/${encodeURIComponent(sessionId)}`)
|
||||
const status = String(st?.status || '')
|
||||
if (status === 'success') {
|
||||
toast(t('engine.hermesOAuthConnected'), 'success')
|
||||
modal.remove?.()
|
||||
await load()
|
||||
break
|
||||
}
|
||||
if (status === 'failed' || status === 'expired') {
|
||||
const statusEl = modal.querySelector?.('#hm-oauth-device-status')
|
||||
if (statusEl) {
|
||||
const errMsg = st?.error_message || t('engine.hermesOAuthDeviceFailed')
|
||||
statusEl.innerHTML = `<span style="color:var(--error)">${escHtml(errMsg)}</span>`
|
||||
}
|
||||
break
|
||||
}
|
||||
// 仍 pending — 继续轮询
|
||||
} catch (e) {
|
||||
// 404 = session 已 GC,停轮询
|
||||
if (String(e?.message).includes('404')) break
|
||||
// 其他错误:继续轮询(短暂网络问题)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function onDisconnect(providerId) {
|
||||
try {
|
||||
await api.hermesDashboardApi('DELETE', `${OAUTH_BASE}/${encodeURIComponent(providerId)}`)
|
||||
toast(t('engine.hermesOAuthDisconnected'), 'success')
|
||||
await load()
|
||||
} catch (e) {
|
||||
toast(humanizeError(e, t('engine.hermesOAuthDisconnectFailed')), 'error')
|
||||
}
|
||||
}
|
||||
|
||||
draw()
|
||||
load()
|
||||
return el
|
||||
}
|
||||
@@ -523,6 +523,40 @@ export default {
|
||||
chatSpeak: _('朗读', 'Speak', '朗讀'),
|
||||
chatSpeakShort: _('朗读', 'Speak', '朗讀'),
|
||||
chatSpeakFailed: _('朗读失败(浏览器不支持或无可用语音)', 'TTS failed (browser unsupported or no voices)', '朗讀失敗(瀏覽器不支援或無可用語音)'),
|
||||
// Batch 3 §Q: OAuth 三种登录(PKCE / device_code / external CLI)
|
||||
hermesOAuthTitle: _('OAuth 登录', 'OAuth Login', 'OAuth 登入'),
|
||||
hermesOAuthDesc: _('用 OAuth 连接 Claude / Qwen / GitHub Copilot 等服务,免去手填 API Key', 'Connect to Claude / Qwen / GitHub Copilot via OAuth — no manual API key needed', '用 OAuth 連接 Claude / Qwen / GitHub Copilot 等服務,免去手填 API Key'),
|
||||
hermesOAuthEmpty: _('未检测到 OAuth Provider(请先启动 Dashboard)', 'No OAuth providers (start Dashboard first)', '未偵測到 OAuth Provider(請先啟動 Dashboard)'),
|
||||
hermesOAuthConnect: _('登录', 'Connect', '登入'),
|
||||
hermesOAuthDisconnect: _('断开', 'Disconnect', '中斷'),
|
||||
hermesOAuthDocs: _('文档', 'Docs', '文件'),
|
||||
hermesOAuthConnected: _('已连接', 'Connected', '已連線'),
|
||||
hermesOAuthDisconnected: _('未连接', 'Disconnected', '未連線'),
|
||||
hermesOAuthExpires: _('过期', 'Expires', '到期'),
|
||||
hermesOAuthFlowPkce: _('PKCE 授权', 'PKCE flow', 'PKCE 授權'),
|
||||
hermesOAuthFlowDevice: _('设备码流程', 'Device code flow', '裝置碼流程'),
|
||||
hermesOAuthFlowExternal: _('需通过 CLI 登录', 'CLI-only flow', '需透過 CLI 登入'),
|
||||
hermesOAuthExternalTitle: _('{id} 需用 CLI 登录', '{id} requires CLI login', '{id} 需用 CLI 登入'),
|
||||
hermesOAuthExternalHint: _('请在终端运行以下命令完成登录:', 'Please run this command in your terminal:', '請在終端執行以下指令完成登入:'),
|
||||
hermesOAuthExternalRefresh: _('登录完成后回到此页面点「刷新」', 'After login, click Refresh on this page', '登入完成後回到此頁面點「重新整理」'),
|
||||
hermesOAuthStartFailed: _('启动登录失败', 'Start login failed', '啟動登入失敗'),
|
||||
hermesOAuthBadResponse: _('OAuth 响应缺少必要字段', 'OAuth response missing required fields', 'OAuth 回應缺少必要欄位'),
|
||||
hermesOAuthUnknownFlow: _('未知流程: {flow}', 'Unknown flow: {flow}', '未知流程: {flow}'),
|
||||
// PKCE
|
||||
hermesOAuthPkceTitle: _('完成 PKCE 授权', 'Complete PKCE authorization', '完成 PKCE 授權'),
|
||||
hermesOAuthPkceAuthLink: _('授权链接(已自动打开)', 'Authorization URL (already opened)', '授權連結(已自動開啟)'),
|
||||
hermesOAuthPkceUrlHint: _('如未自动打开浏览器,请手动复制', 'If browser did not open, copy this URL', '如未自動開啟瀏覽器,請手動複製'),
|
||||
hermesOAuthPkceCodeLabel: _('粘贴回调 Code', 'Paste callback code', '貼上回呼 Code'),
|
||||
hermesOAuthPkceCodeHint: _('授权后浏览器会跳转到一个回调页,从 URL 或页面文字复制 code', 'After authorizing, the browser redirects to a callback page — copy the code from URL or text', '授權後瀏覽器會跳轉到一個回呼頁,從 URL 或頁面文字複製 code'),
|
||||
hermesOAuthCodeRequired: _('请填写授权 code', 'Code is required', '請填寫授權 code'),
|
||||
hermesOAuthSubmitFailed: _('提交回调 code 失败', 'Submit code failed', '提交回呼 code 失敗'),
|
||||
// device code
|
||||
hermesOAuthDeviceTitle: _('完成设备码登录', 'Complete device code login', '完成裝置碼登入'),
|
||||
hermesOAuthDeviceHint: _('1) 浏览器会自动打开授权页 2) 输入下方 6 位用户码 3) 完成后此处自动刷新', '1) Browser opens the auth page 2) Enter the user code below 3) Page auto-refreshes when done', '1) 瀏覽器會自動開啟授權頁 2) 輸入下方 6 位用戶碼 3) 完成後此處自動重新整理'),
|
||||
hermesOAuthDeviceWaiting: _('等待你在浏览器完成登录…', 'Waiting for you to complete login in browser…', '等待你在瀏覽器完成登入…'),
|
||||
hermesOAuthDeviceTimeout: _('登录超时(10 分钟未完成)', 'Login timed out (10 min)', '登入逾時(10 分鐘未完成)'),
|
||||
hermesOAuthDeviceFailed: _('授权失败', 'Authorization failed', '授權失敗'),
|
||||
hermesOAuthDisconnectFailed: _('断开失败', 'Disconnect failed', '中斷失敗'),
|
||||
// Web 模式(远程浏览器)下流式聊天暂不可用
|
||||
chatWebModeStreamingUnsupported: _(
|
||||
'Web 模式暂不支持 Hermes 实时流式聊天(依赖桌面端事件桥)。请打开桌面客户端使用此功能。',
|
||||
|
||||
Reference in New Issue
Block a user