feat(client): one-click Manager OAuth login

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:<port>/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) <noreply@anthropic.com>
This commit is contained in:
2026-05-08 20:34:05 +08:00
co-authored by Claude Opus 4.7
parent 87b30b8fa1
commit bb3c2c707b
3 changed files with 74 additions and 76 deletions
@@ -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<Phase>('idle')
const [localError, setLocalError] = useState<string | null>(null)
const [busy, setBusy] = useState<boolean>(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 (
<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))]">
<div className="text-center">
@@ -67,47 +90,20 @@ export function ProviderLoginCard({ provider }: Props) {
</p>
</div>
{/* ─── 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>
<button
type="button"
onClick={handleBrowserLogin}
disabled={buttonDisabled}
className="rounded-[var(--radius-md)] border border-[var(--color-primary)]/40 bg-[var(--color-primary)] px-4 py-3 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"
>
{buttonLabel}
</button>
<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="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 ? t('login.creds.submitting') : t('login.creds.submit')}
</button>
</form>
{phase === 'waiting' ? (
<p className="text-center text-[11px] leading-relaxed text-[var(--color-text-tertiary)]">
{t('login.oauth.waiting')}
</p>
) : 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)]">
+2 -1
View File
@@ -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 →',
+2 -1
View File
@@ -1070,8 +1070,9 @@ export const zh: Record<TranslationKey, string> = {
'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': '或通过浏览器免密登录 →',