feat: email+password login (path B from original design doc)

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 <baseUrl>/api/user/login (username + password)
    2. Capture Set-Cookie from the response
    3. GET <baseUrl>/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) <noreply@anthropic.com>
This commit is contained in:
2026-05-07 14:08:54 +08:00
co-authored by Claude Opus 4.7
parent 4665f88921
commit c12e19b1ce
6 changed files with 252 additions and 40 deletions
+15
View File
@@ -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<HeicodeLoginResult>(
'/api/heicode-auth/login-with-credentials',
input,
{ timeout: 60_000 },
)
},
// OAuth 当前是占位,但保留方法形态以便未来无痛切换。
startOAuth(providerId: HeicodeProviderId) {
return api.post<{ authorizeUrl: string; state: string }>(
@@ -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<string | null>(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 (
<div className="relative flex flex-col gap-5 rounded-[var(--radius-lg)] border border-[var(--color-border)] bg-[var(--color-surface-container)] p-7 shadow-[var(--shadow-dropdown)] backdrop-blur-sm transition-colors duration-200 hover:border-[var(--color-border-hi,rgba(255,255,255,0.14))]">
@@ -73,18 +93,11 @@ export function ProviderLoginCard({ provider }: Props) {
<h3 className="text-base font-semibold tracking-wide text-[var(--color-text-primary)]">
{provider.name}
</h3>
{provider.oauthEnabled ? (
<span className="rounded-full border border-[var(--color-success)]/25 bg-[var(--color-success)]/10 px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-success)]">
{t('login.tags.recommended')}
</span>
) : (
<span className="rounded-full border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{t('login.tags.comingSoon')}
</span>
)}
<span className="rounded-full border border-[var(--color-success)]/25 bg-[var(--color-success)]/10 px-2.5 py-0.5 text-[10px] font-medium uppercase tracking-wider text-[var(--color-success)]">
{t('login.tags.recommended')}
</span>
</div>
{/* baseUrl indicator */}
<div
className="flex flex-wrap items-center gap-1.5 text-[11px] text-[var(--color-text-tertiary)]"
data-testid={`heicode-login-card-baseurl-${provider.id}`}
@@ -97,7 +110,6 @@ export function ProviderLoginCard({ provider }: Props) {
<span
className="rounded-full border border-[var(--color-warning)]/25 bg-[var(--color-warning)]/10 px-2 py-0.5 text-[10px] uppercase tracking-wider text-[var(--color-warning)]"
title={t('login.baseUrl.localHint')}
data-testid={`heicode-login-card-local-tag-${provider.id}`}
>
{t('login.baseUrl.localTag')}
</span>
@@ -110,25 +122,61 @@ export function ProviderLoginCard({ provider }: Props) {
</p>
) : null}
<div className="flex flex-col gap-2">
{/* ─── Primary: Email + Password ───────────────────────── */}
<form onSubmit={handleCredentialsLogin} className="flex flex-col gap-3">
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{t('login.creds.email')}
</span>
<input
type="email"
inputMode="email"
autoComplete="username"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
disabled={formDisabled}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-primary)] focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium uppercase tracking-wider text-[var(--color-text-tertiary)]">
{t('login.creds.password')}
</span>
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="••••••••"
disabled={formDisabled}
className="w-full rounded-[var(--radius-md)] border border-[var(--color-border)] bg-[var(--color-surface-container-low)] px-3 py-2 text-sm text-[var(--color-text-primary)] placeholder:text-[var(--color-text-tertiary)] outline-none transition-colors focus:border-[var(--color-primary)] focus:shadow-[var(--shadow-focus-ring)] disabled:cursor-not-allowed disabled:opacity-60"
/>
</label>
<button
type="button"
onClick={handleOAuth}
disabled={!provider.oauthEnabled || isLoggingIn || busy !== null}
className="group/btn relative overflow-hidden rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-[var(--color-on-primary)] shadow-[var(--shadow-button-primary)] transition-all duration-200 hover:bg-[var(--color-primary-fixed-dim)] hover:shadow-[0_8px_24px_rgba(197,165,114,0.28)] disabled:cursor-not-allowed disabled:border-[var(--color-border)] disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)] disabled:shadow-none"
type="submit"
disabled={!submitEnabled}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-2.5 text-sm font-medium text-[var(--color-on-primary)] shadow-[var(--shadow-button-primary)] transition-all duration-200 hover:bg-[var(--color-primary-fixed-dim)] hover:shadow-[0_8px_24px_rgba(197,165,114,0.28)] disabled:cursor-not-allowed disabled:border-[var(--color-border)] disabled:bg-[var(--color-surface-container-high)] disabled:text-[var(--color-text-tertiary)] disabled:shadow-none"
>
{busy === 'oauth'
? t('login.oauth.opening')
: provider.oauthEnabled
? t('login.oauth.button')
: t('login.oauth.disabled')}
{busy === 'creds' ? t('login.creds.submitting') : t('login.creds.submit')}
</button>
{!provider.oauthEnabled ? (
<p className="text-xs text-[var(--color-text-tertiary)]">
{t('login.oauth.disabledHint')}
</p>
) : null}
</div>
</form>
{/* ─── Secondary: Browser OAuth (保留快捷入口) ─────────── */}
{provider.oauthEnabled ? (
<div className="flex items-center justify-center pt-1">
<button
type="button"
onClick={handleOAuth}
disabled={formDisabled}
className="text-xs text-[var(--color-text-tertiary)] underline-offset-4 transition-colors hover:text-[var(--color-text-primary)] hover:underline disabled:cursor-not-allowed disabled:opacity-50"
>
{busy === 'oauth' ? t('login.oauth.opening') : t('login.oauth.altLink')}
</button>
</div>
) : null}
{localError ? (
<div className="rounded-[var(--radius-md)] border border-[var(--color-error)]/30 bg-[var(--color-error-container)] px-3 py-2 text-xs text-[var(--color-error)]">
+5
View File
@@ -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-…',
+5
View File
@@ -966,6 +966,11 @@ export const zh: Record<TranslationKey, string> = {
'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-…',
@@ -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<void>
refreshStatus: () => Promise<void>
loginWithApiKey: (input: HeicodeLoginInput) => Promise<HeicodeLoginResult>
loginWithCredentials: (
input: HeicodeCredentialsLoginInput,
) => Promise<HeicodeLoginResult>
startOAuth: (providerId: HeicodeLoginProviderInfo['id']) => Promise<{ authorizeUrl: string }>
startOAuthPolling: () => void
stopOAuthPolling: () => void
@@ -91,6 +95,20 @@ export const useHeicodeAuthStore = create<HeicodeAuthState>((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 {
+121
View File
@@ -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<Response> {
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 <baseUrl>/api/user/login → grab Set-Cookie session + user.id
* 2. GET <baseUrl>/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<Response> {
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<Response> {