From bb3c2c707bfb3b7421127b3bcd63a92a6a763713 Mon Sep 17 00:00:00 2001 From: chenchen Date: Fri, 8 May 2026 20:34:05 +0800 Subject: [PATCH] feat(client): one-click Manager OAuth login MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the email + password form on the desktop login card with a single "一键登录 Heicode Manager" button that drives the existing OAuth bridge: desktop click → POST /api/heicode-auth/oauth/start (server stages state + builds authorize URL pointing at https://code.xinghanlab.com/heicode/oauth/authorize) → Tauri shell.open() the authorize URL in the system browser → user signs in via Manager (which now also routes /sign-in?redirect=...) → Manager 302s back to http://127.0.0.1:/api/heicode-auth/oauth/callback?token=sk-… → callback handler activates the provider; status flips loggedIn=true → AppShell unmounts the login page The OAuth start/callback endpoints already existed (handleOAuthStart / handleOAuthCallback) so this is a UI-only swap; no auth-store changes. loginWithCredentials remains exported in case we ever need a fallback, but it's no longer wired into any UI surface. i18n: tweak login.oauth.button to "一键登录 Heicode Manager", add login.oauth.waiting for the polling state. Aligns with upstream xiaohei/heicode commits 5bd8276 / e60e74b / 34a87a4 (Manager-as-only-identity) without breaking the slice 11-14 heicode-tasks proxy that still depends on the provider abstraction. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../components/login/ProviderLoginCard.tsx | 144 +++++++++--------- cc-haha/desktop/src/i18n/locales/en.ts | 3 +- cc-haha/desktop/src/i18n/locales/zh.ts | 3 +- 3 files changed, 74 insertions(+), 76 deletions(-) diff --git a/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx b/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx index 4a02bb5..0afc5f2 100644 --- a/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx +++ b/cc-haha/desktop/src/components/login/ProviderLoginCard.tsx @@ -1,9 +1,25 @@ // desktop/src/components/login/ProviderLoginCard.tsx // -// Minimal email + password sign-in card per -// docs/product-package/11-product-prototype-wireframes.md §1. +// One-click browser login card. The desktop app delegates auth to Heicode +// Manager (https://code.xinghanlab.com/sign-in?redirect=...) and waits for +// the local OAuth callback to receive an sk- token. +// +// Flow: +// 1. User clicks「一键登录」 +// 2. We POST /api/heicode-auth/oauth/start → server stages a state token +// and builds the authorize URL (Heicode Manager + redirect_uri pointing +// back at our local Bun server's /api/heicode-auth/oauth/callback) +// 3. We open the authorize URL in the system browser via Tauri shell +// 4. Manager 302-redirects back to our localhost callback with ?token=sk-… +// 5. Server-side handleOAuthCallback exchanges the token + activates the +// provider; auth status flips loggedIn=true; AppShell switches surface +// +// We don't show an email/password form here anymore — Manager owns the +// login UX. If the user can't reach Manager (no network etc.), they see +// the inline error message and can retry. import { useState } from 'react' +import { open as shellOpen } from '@tauri-apps/plugin-shell' import type { HeicodeLoginProviderInfo } from '../../api/heicodeAuth' import { useHeicodeAuthStore } from '../../stores/heicodeAuthStore' import { useTranslation } from '../../i18n' @@ -12,42 +28,15 @@ type Props = { provider: HeicodeLoginProviderInfo } +type Phase = 'idle' | 'opening' | 'waiting' | 'failed' + export function ProviderLoginCard({ provider }: Props) { const t = useTranslation() - const { loginWithCredentials, isLoggingIn } = useHeicodeAuthStore() - const [email, setEmail] = useState('') - const [password, setPassword] = useState('') + const startOAuth = useHeicodeAuthStore((s) => s.startOAuth) + const startOAuthPolling = useHeicodeAuthStore((s) => s.startOAuthPolling) + const [phase, setPhase] = useState('idle') const [localError, setLocalError] = useState(null) - const [busy, setBusy] = useState(false) - const handleCredentialsLogin = async (e: React.FormEvent) => { - e.preventDefault() - if (busy || isLoggingIn) return - setLocalError(null) - setBusy(true) - try { - await loginWithCredentials({ - providerId: provider.id, - email: email.trim(), - password, - }) - // After success store.status.loggedIn=true → AppShell switches surface. - } catch (err) { - setLocalError(err instanceof Error ? err.message : String(err)) - } finally { - setBusy(false) - } - } - - const formDisabled = busy || isLoggingIn - const submitEnabled = email.trim().length >= 3 && password.length >= 1 && !formDisabled - - // Per docs/product-package/11-product-prototype-wireframes.md §1, the login - // surface is intentionally minimal: brand wordmark + sign-in target host + - // single sign-in action. We surface the host as a small, low-contrast label - // (so the user knows where the credentials are going) but drop the raw - // baseUrl pill, the promo text, the local-network warning tag, and the - // "or via browser" alt link that earlier slices added. const signInHost = (() => { try { return new URL(provider.baseUrl).host @@ -56,6 +45,40 @@ export function ProviderLoginCard({ provider }: Props) { } })() + const handleBrowserLogin = async () => { + if (phase === 'opening' || phase === 'waiting') return + setLocalError(null) + setPhase('opening') + try { + const { authorizeUrl } = await startOAuth(provider.id) + try { + await shellOpen(authorizeUrl) + } catch { + // Tauri shell unavailable (e.g. dev in browser). Fall back to a new + // window opened via the renderer. + window.open(authorizeUrl, '_blank', 'noopener,noreferrer') + } + setPhase('waiting') + startOAuthPolling() + // Once the OAuth callback fires server-side, status.loggedIn flips + // true and AppShell unmounts this page, so we don't need to clean up + // the 'waiting' state here. + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + setLocalError(message) + setPhase('failed') + } + } + + const buttonLabel = + phase === 'opening' + ? t('login.oauth.opening') + : phase === 'waiting' + ? t('login.oauth.opening') + : t('login.oauth.button') + + const buttonDisabled = phase === 'opening' || phase === 'waiting' + return (
@@ -67,47 +90,20 @@ export function ProviderLoginCard({ provider }: Props) {

- {/* ─── Primary: Email + Password ───────────────────────── */} -
- + - - - -
+ {phase === 'waiting' ? ( +

+ {t('login.oauth.waiting')} +

+ ) : null} {localError ? (
diff --git a/cc-haha/desktop/src/i18n/locales/en.ts b/cc-haha/desktop/src/i18n/locales/en.ts index 1c88965..c5693ad 100644 --- a/cc-haha/desktop/src/i18n/locales/en.ts +++ b/cc-haha/desktop/src/i18n/locales/en.ts @@ -1068,8 +1068,9 @@ export const en = { 'login.footnote': 'Heicode talks directly to TaijiAICloud; your API key never leaves this machine.', 'login.tags.recommended': 'Recommended', 'login.tags.comingSoon': 'Coming soon', - 'login.oauth.button': 'Sign in with browser', + 'login.oauth.button': 'Sign in via Heicode Manager', 'login.oauth.opening': 'Opening browser…', + 'login.oauth.waiting': 'Waiting for you to finish signing in. This page will redirect automatically.', '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 →', diff --git a/cc-haha/desktop/src/i18n/locales/zh.ts b/cc-haha/desktop/src/i18n/locales/zh.ts index 41dbea4..f3da603 100644 --- a/cc-haha/desktop/src/i18n/locales/zh.ts +++ b/cc-haha/desktop/src/i18n/locales/zh.ts @@ -1070,8 +1070,9 @@ export const zh: Record = { 'login.footnote': 'Heicode 直接连 TaijiAICloud,API Key 仅保存在你这台机器上。', 'login.tags.recommended': '推荐', 'login.tags.comingSoon': '即将开放', - 'login.oauth.button': '浏览器登录', + 'login.oauth.button': '一键登录 Heicode Manager', 'login.oauth.opening': '正在打开浏览器…', + 'login.oauth.waiting': '已在浏览器中打开 Manager。请在浏览器内完成登录,本页会自动跳转。', 'login.oauth.disabled': '浏览器登录(即将开放)', 'login.oauth.disabledHint': '等平台开放 OAuth 授权端点后,浏览器登录会立即可用。', 'login.oauth.altLink': '或通过浏览器免密登录 →',