From c12e19b1ce3549027f104a3a540ab7c0b4555c0e Mon Sep 17 00:00:00 2001 From: chenchen Date: Thu, 7 May 2026 14:08:54 +0800 Subject: [PATCH] feat: email+password login (path B from original design doc) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per docs/integration/Heicode-登录接口对接文档.md, the original Heicode desktop is supposed to take email+password directly, hand them to the Manager (POST /api/user/login), and use the resulting session to acquire an LLM access token. The previous flow opened a system browser and redirected through /heicode/oauth/authorize, which works but deviates from the design and forces an extra round trip. This commit adds the documented in-process flow as the primary login path while keeping browser OAuth as a fallback link: POST /api/heicode-auth/login-with-credentials 1. POST /api/user/login (username + password) 2. Capture Set-Cookie from the response 3. GET /heicode/oauth/authorize?... with that cookie and redirect: 'manual' 4. Parse Location: ...?token=sk-XXXX, hand it to loginAndActivate The whole chain stays inside the local cc-haha server — no browser is opened, no token leaves the user's machine. UI changes: - ProviderLoginCard now shows email + password fields as the primary form, with the existing "or via browser" OAuth path demoted to a small link below. - Added store action loginWithCredentials and matching API client method. - i18n keys: login.creds.{email,password,submit,submitting} + login.oauth.altLink (zh + en). Co-Authored-By: Claude Opus 4.7 (1M context) --- cc-haha/desktop/src/api/heicodeAuth.ts | 15 ++ .../components/login/ProviderLoginCard.tsx | 128 ++++++++++++------ cc-haha/desktop/src/i18n/locales/en.ts | 5 + cc-haha/desktop/src/i18n/locales/zh.ts | 5 + .../desktop/src/stores/heicodeAuthStore.ts | 18 +++ cc-haha/src/server/api/heicode-auth.ts | 121 +++++++++++++++++ 6 files changed, 252 insertions(+), 40 deletions(-) diff --git a/cc-haha/desktop/src/api/heicodeAuth.ts b/cc-haha/desktop/src/api/heicodeAuth.ts index b33b509..c5cb3f9 100644 --- a/cc-haha/desktop/src/api/heicodeAuth.ts +++ b/cc-haha/desktop/src/api/heicodeAuth.ts @@ -37,6 +37,13 @@ export type HeicodeLoginInput = { displayName?: string } +export type HeicodeCredentialsLoginInput = { + providerId: HeicodeProviderId + email: string + password: string + displayName?: string +} + export type HeicodeLoginResult = { ok: true provider: { @@ -70,6 +77,14 @@ export const heicodeAuthApi = { ) }, + loginWithCredentials(input: HeicodeCredentialsLoginInput) { + return api.post( + '/api/heicode-auth/login-with-credentials', + input, + { timeout: 60_000 }, + ) + }, + // OAuth 当前是占位,但保留方法形态以便未来无痛切换。 startOAuth(providerId: HeicodeProviderId) { return api.post<{ authorizeUrl: string; state: string }>( diff --git a/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx b/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx index 1a235e5..91b1329 100644 --- a/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx +++ b/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx @@ -1,8 +1,8 @@ // desktop/src/components/login/ProviderLoginCard.tsx // -// 单个登录入口卡片。两种登录方式同时呈现: -// 1. 浏览器跳转 OAuth (推荐) —— 平台支持时启用 -// 2. 复制粘贴 API Key —— 兼容入口 +// 登录卡片: +// 主路径 — 邮箱+密码直登(路径 B,符合原设计文档) +// 次路径 — 浏览器跳转 OAuth(保留作为快捷免密登录入口) import { useState } from 'react' import { open as shellOpen } from '@tauri-apps/plugin-shell' @@ -14,13 +14,6 @@ type Props = { provider: HeicodeLoginProviderInfo } -/** - * Treat any of the following as a "local override": - * - http:// (not https://) — typical for in-cluster gateways - * - localhost / 127.0.0.1 - * - 10/172.16-31/192.168 RFC1918 ranges - * - any hostname without a dot (e.g. `heicode`, `heicode-server`) — Docker DNS aliases - */ function isLocalBaseUrl(rawUrl: string): boolean { if (!rawUrl) return false let host: string @@ -41,10 +34,35 @@ function isLocalBaseUrl(rawUrl: string): boolean { export function ProviderLoginCard({ provider }: Props) { const t = useTranslation() - const { startOAuth, startOAuthPolling, isLoggingIn } = - useHeicodeAuthStore() + const { + loginWithCredentials, + startOAuth, + startOAuthPolling, + isLoggingIn, + } = useHeicodeAuthStore() + const [email, setEmail] = useState('') + const [password, setPassword] = useState('') const [localError, setLocalError] = useState(null) - const [busy, setBusy] = useState<'oauth' | null>(null) + const [busy, setBusy] = useState<'creds' | 'oauth' | null>(null) + + const handleCredentialsLogin = async (e: React.FormEvent) => { + e.preventDefault() + if (busy !== null || isLoggingIn) return + setLocalError(null) + setBusy('creds') + try { + await loginWithCredentials({ + providerId: provider.id, + email: email.trim(), + password, + }) + // 登录成功后 store.status.loggedIn=true,AppShell 自动切到主界面 + } catch (err) { + setLocalError(err instanceof Error ? err.message : String(err)) + } finally { + setBusy(null) + } + } const handleOAuth = async () => { if (!provider.oauthEnabled) return @@ -66,6 +84,8 @@ export function ProviderLoginCard({ provider }: Props) { } const local = isLocalBaseUrl(provider.baseUrl) + const formDisabled = busy !== null || isLoggingIn + const submitEnabled = email.trim().length >= 3 && password.length >= 1 && !formDisabled return (
@@ -73,18 +93,11 @@ export function ProviderLoginCard({ provider }: Props) {

{provider.name}

- {provider.oauthEnabled ? ( - - {t('login.tags.recommended')} - - ) : ( - - {t('login.tags.comingSoon')} - - )} + + {t('login.tags.recommended')} +
- {/* baseUrl indicator */}
{t('login.baseUrl.localTag')} @@ -110,25 +122,61 @@ export function ProviderLoginCard({ provider }: Props) {

) : null} -
+ {/* ─── Primary: Email + Password ───────────────────────── */} +
+ + + + - {!provider.oauthEnabled ? ( -

- {t('login.oauth.disabledHint')} -

- ) : null} -
+ + + {/* ─── Secondary: Browser OAuth (保留快捷入口) ─────────── */} + {provider.oauthEnabled ? ( +
+ +
+ ) : null} {localError ? (
diff --git a/cc-haha/desktop/src/i18n/locales/en.ts b/cc-haha/desktop/src/i18n/locales/en.ts index f1edccd..671b2d9 100644 --- a/cc-haha/desktop/src/i18n/locales/en.ts +++ b/cc-haha/desktop/src/i18n/locales/en.ts @@ -964,6 +964,11 @@ export const en = { 'login.oauth.opening': 'Opening browser…', 'login.oauth.disabled': 'Browser sign-in (coming soon)', 'login.oauth.disabledHint': 'OAuth will become available once the platform exposes its authorize endpoint.', + 'login.oauth.altLink': 'Or sign in via browser →', + 'login.creds.email': 'Email', + 'login.creds.password': 'Password', + 'login.creds.submit': 'Sign in', + 'login.creds.submitting': 'Signing in…', 'login.divider.or': 'or', 'login.paste.label': 'Paste API Key', 'login.paste.placeholder': 'sk-…', diff --git a/cc-haha/desktop/src/i18n/locales/zh.ts b/cc-haha/desktop/src/i18n/locales/zh.ts index acff106..c3e0b38 100644 --- a/cc-haha/desktop/src/i18n/locales/zh.ts +++ b/cc-haha/desktop/src/i18n/locales/zh.ts @@ -966,6 +966,11 @@ export const zh: Record = { 'login.oauth.opening': '正在打开浏览器…', 'login.oauth.disabled': '浏览器登录(即将开放)', 'login.oauth.disabledHint': '等平台开放 OAuth 授权端点后,浏览器登录会立即可用。', + 'login.oauth.altLink': '或通过浏览器免密登录 →', + 'login.creds.email': '邮箱', + 'login.creds.password': '密码', + 'login.creds.submit': '登录', + 'login.creds.submitting': '登录中…', 'login.divider.or': '或', 'login.paste.label': '粘贴 API Key', 'login.paste.placeholder': 'sk-…', diff --git a/cc-haha/desktop/src/stores/heicodeAuthStore.ts b/cc-haha/desktop/src/stores/heicodeAuthStore.ts index 5b46943..36078d1 100644 --- a/cc-haha/desktop/src/stores/heicodeAuthStore.ts +++ b/cc-haha/desktop/src/stores/heicodeAuthStore.ts @@ -6,6 +6,7 @@ import { create } from 'zustand' import { heicodeAuthApi, type HeicodeAuthStatus, + type HeicodeCredentialsLoginInput, type HeicodeLoginInput, type HeicodeLoginProviderInfo, type HeicodeLoginResult, @@ -25,6 +26,9 @@ type HeicodeAuthState = { fetch: () => Promise refreshStatus: () => Promise loginWithApiKey: (input: HeicodeLoginInput) => Promise + loginWithCredentials: ( + input: HeicodeCredentialsLoginInput, + ) => Promise startOAuth: (providerId: HeicodeLoginProviderInfo['id']) => Promise<{ authorizeUrl: string }> startOAuthPolling: () => void stopOAuthPolling: () => void @@ -91,6 +95,20 @@ export const useHeicodeAuthStore = create((set, get) => { } }, + loginWithCredentials: async (input) => { + set({ isLoggingIn: true, error: null }) + try { + const result = await heicodeAuthApi.loginWithCredentials(input) + const status = await heicodeAuthApi.status() + set({ isLoggingIn: false, status }) + return result + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + set({ isLoggingIn: false, error: message }) + throw err + } + }, + startOAuth: async (providerId) => { set({ isLoggingIn: true, error: null }) try { diff --git a/cc-haha/src/server/api/heicode-auth.ts b/cc-haha/src/server/api/heicode-auth.ts index cbe34f2..71a666b 100644 --- a/cc-haha/src/server/api/heicode-auth.ts +++ b/cc-haha/src/server/api/heicode-auth.ts @@ -55,6 +55,13 @@ const OAuthStartSchema = z.object({ providerId: z.enum(['taijiaicloud']), }) +const CredentialsLoginSchema = z.object({ + providerId: z.enum(['taijiaicloud']), + email: z.string().min(3, '请输入登录邮箱或用户名'), + password: z.string().min(1, '请输入密码'), + displayName: z.string().min(1).optional(), +}) + type OAuthSession = { providerId: SupportedLoginProviderId state: string @@ -91,6 +98,11 @@ export async function handleHeicodeAuthApi( return await handleLoginWithApiKey(req) } + // POST /api/heicode-auth/login-with-credentials — 邮箱+密码直登(B 路径) + if (action === 'login-with-credentials' && req.method === 'POST') { + return await handleLoginWithCredentials(req) + } + // GET /api/heicode-auth/status if (action === 'status' && req.method === 'GET') { const status = await providerService.checkAuthStatus() @@ -181,6 +193,115 @@ async function handleLoginWithApiKey(req: Request): Promise { return await loginAndActivate(providerId, apiKey, displayName) } +/** + * Email/password login (path B — matches the original Heicode design doc). + * + * Local server talks to the Manager directly, no browser dance: + * 1. POST /api/user/login → grab Set-Cookie session + user.id + * 2. GET /heicode/oauth/authorize with that cookie + a stub + * loopback redirect_uri → Manager 302's with `token=sk-XXX` in the + * Location header. We capture it without ever following. + * 3. Hand sk-XXX to loginAndActivate, which probes /v1/models and saves. + * + * The session cookie + token mint never leave this process. + */ +async function handleLoginWithCredentials(req: Request): Promise { + const body = await parseJsonBody(req) + const parsed = CredentialsLoginSchema.safeParse(body) + if (!parsed.success) { + throw ApiError.badRequest(parsed.error.issues.map(i => i.message).join('; ')) + } + const { providerId, email, password, displayName } = parsed.data + const preset = getPreset(providerId) + const base = preset.baseUrl.replace(/\/+$/, '') + if (!base) { + throw ApiError.badRequest(`${preset.name} 未配置 baseUrl,无法登录`) + } + + // ── Step 1: POST /api/user/login ─────────────────────────────── + const loginRes = await fetch(`${base}/api/user/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Origin: base, + Accept: 'application/json', + }, + body: JSON.stringify({ username: email, password }), + redirect: 'manual', + signal: AbortSignal.timeout(15000), + }).catch((err) => { + throw ApiError.badRequest( + `无法连接到 ${preset.name}:${err instanceof Error ? err.message : String(err)}`, + ) + }) + + let loginJson: { success?: boolean; message?: string; data?: { id?: number } } = {} + try { + loginJson = (await loginRes.json()) as typeof loginJson + } catch { + /* ignore */ + } + if (!loginJson.success) { + throw ApiError.badRequest(loginJson.message || '登录失败:账号或密码错误') + } + const userId = loginJson.data?.id + if (!userId || userId <= 0) { + throw ApiError.badRequest('登录响应缺少 user.id') + } + + // Collect Set-Cookie for the next request. fetch's getSetCookie() returns + // an array of full Set-Cookie header values; we only need name=value pairs. + const rawSetCookies = + typeof loginRes.headers.getSetCookie === 'function' + ? loginRes.headers.getSetCookie() + : (loginRes.headers.get('set-cookie') ?? '').split(/,(?=[^;]+=)/) + const cookieHeader = rawSetCookies + .map((sc) => sc.split(';')[0]?.trim()) + .filter((p): p is string => Boolean(p)) + .join('; ') + if (!cookieHeader) { + throw ApiError.badRequest('Manager 没有返回 session cookie,无法继续') + } + + // ── Step 2: GET /heicode/oauth/authorize → capture token from 302 ── + const state = randomUrlSafe(24) + const stubRedirect = 'http://127.0.0.1:1/_heicode_stub' + const authorizeUrl = + `${base}/heicode/oauth/authorize?` + + `state=${encodeURIComponent(state)}` + + `&redirect_uri=${encodeURIComponent(stubRedirect)}` + + `&provider_id=${encodeURIComponent(providerId)}` + + `&response_type=code&callback_mode=token_or_code` + + const authRes = await fetch(authorizeUrl, { + method: 'GET', + headers: { + Cookie: cookieHeader, + 'New-Api-User': String(userId), + Accept: 'text/html,application/xhtml+xml', + }, + redirect: 'manual', + signal: AbortSignal.timeout(15000), + }) + + if (authRes.status !== 302 && authRes.status !== 301 && authRes.status !== 303) { + throw ApiError.badRequest( + `授权端点未返回重定向(HTTP ${authRes.status}),可能 session 未生效或后端未实装 /heicode/oauth/authorize`, + ) + } + const location = authRes.headers.get('location') || '' + const tokenMatch = location.match(/[?&]token=(sk-[A-Za-z0-9_-]+)/) + const token = tokenMatch?.[1] + if (!token) { + throw ApiError.badRequest( + '授权重定向未携带 token,请确认 Manager 已部署最新版 heicode_oauth.go', + ) + } + + // ── Step 3: Save + activate via the existing pipeline ───────── + return await loginAndActivate(providerId, token, displayName) +} + // ─── OAuth scaffold (TODO: 平台支持后实装) ────────────────────── async function handleOAuthStart(req: Request, url: URL): Promise {