mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-17 11:07:11 +08:00
fix(desktop): support web_password auth and fix password settings
This commit is contained in:
@@ -315,7 +315,10 @@ export class PythonBackend extends EventEmitter {
|
||||
const startedAt = Date.now()
|
||||
|
||||
const check = () => {
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/config`, (res) => {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: /config
|
||||
// requires auth once a web_password is set, which would make this poll
|
||||
// 401 forever and hang startup.
|
||||
const req = http.get(`http://127.0.0.1:${this.port}/api/health`, (res) => {
|
||||
if (res.statusCode === 200) {
|
||||
this.status = 'ready'
|
||||
this.emit('log', `Backend ready on port ${this.port}`)
|
||||
|
||||
@@ -5,6 +5,7 @@ import NavRail from './layout/NavRail'
|
||||
import SessionList from './layout/SessionList'
|
||||
import WindowControls from './layout/WindowControls'
|
||||
import StatusScreen from './components/StatusScreen'
|
||||
import LoginGate from './components/LoginGate'
|
||||
import { useBackend } from './hooks/useBackend'
|
||||
import { usePlatform } from './hooks/usePlatform'
|
||||
import { useUIStore } from './store/uiStore'
|
||||
@@ -32,16 +33,45 @@ const App: React.FC = () => {
|
||||
const onboardingOpen = useOnboardingStore((s) => s.open)
|
||||
const maybeOpenOnboarding = useOnboardingStore((s) => s.maybeOpen)
|
||||
const [, forceUpdate] = useState(0)
|
||||
// Auth gate for web_password-protected backends. 'checking' until we know
|
||||
// whether login is needed; 'need_login' shows the password screen; 'ok' lets
|
||||
// the main UI render.
|
||||
const [authState, setAuthState] = useState<'checking' | 'need_login' | 'ok'>('checking')
|
||||
|
||||
useEffect(() => {
|
||||
if (backend.status === 'ready') apiClient.setBaseUrl(backend.baseUrl)
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// Once the backend is ready, check whether a web_password is set. If so and
|
||||
// this session isn't authenticated, show the login gate before the app.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') {
|
||||
setAuthState('checking')
|
||||
return
|
||||
}
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.authCheck()
|
||||
.then((res) => {
|
||||
if (cancelled) return
|
||||
const needLogin = res.auth_required && !res.authenticated
|
||||
setAuthState(needLogin ? 'need_login' : 'ok')
|
||||
})
|
||||
.catch(() => {
|
||||
// If the check itself fails, don't hard-block the user — assume no auth
|
||||
// is required (backends without web_password never return errors here).
|
||||
if (!cancelled) setAuthState('ok')
|
||||
})
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, backend.baseUrl])
|
||||
|
||||
// First-run check: once the backend is ready, decide whether to show the
|
||||
// onboarding wizard. It's config-driven — shown whenever the chat model isn't
|
||||
// configured (and not dismissed earlier this session); no persisted flag.
|
||||
useEffect(() => {
|
||||
if (backend.status !== 'ready') return
|
||||
if (backend.status !== 'ready' || authState !== 'ok') return
|
||||
let cancelled = false
|
||||
apiClient
|
||||
.getModels()
|
||||
@@ -65,7 +95,7 @@ const App: React.FC = () => {
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [backend.status, maybeOpenOnboarding])
|
||||
}, [backend.status, authState, maybeOpenOnboarding])
|
||||
|
||||
// Subscribe to auto-update status from the main process (no-op in dev).
|
||||
useEffect(() => initUpdateListener(), [])
|
||||
@@ -91,6 +121,15 @@ const App: React.FC = () => {
|
||||
return <StatusScreen status={backend.status} error={backend.error} onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
// Backend is up but we're still resolving auth — keep the loading screen.
|
||||
if (authState === 'checking') {
|
||||
return <StatusScreen status="connecting" onRetry={backend.restart} />
|
||||
}
|
||||
|
||||
if (authState === 'need_login') {
|
||||
return <LoginGate onAuthenticated={() => setAuthState('ok')} />
|
||||
}
|
||||
|
||||
const isChat = location.pathname === '/'
|
||||
const showSessions = isChat && !sessionsCollapsed
|
||||
|
||||
|
||||
@@ -24,8 +24,16 @@ interface ApiResult {
|
||||
message?: string
|
||||
}
|
||||
|
||||
const AUTH_TOKEN_KEY = 'cow_auth_token'
|
||||
|
||||
class ApiClient {
|
||||
private baseUrl = 'http://127.0.0.1:9876'
|
||||
// Bearer token for web_password-protected backends. The desktop renderer
|
||||
// runs from a file:// origin, where cross-origin cookies to http://127.0.0.1
|
||||
// aren't sent reliably, so we authenticate via an Authorization header
|
||||
// instead. Persisted in localStorage so it survives reloads.
|
||||
private authToken: string | null =
|
||||
typeof localStorage !== 'undefined' ? localStorage.getItem(AUTH_TOKEN_KEY) : null
|
||||
|
||||
setBaseUrl(url: string) {
|
||||
this.baseUrl = url
|
||||
@@ -35,13 +43,25 @@ class ApiClient {
|
||||
return this.baseUrl
|
||||
}
|
||||
|
||||
setAuthToken(token: string | null) {
|
||||
this.authToken = token
|
||||
try {
|
||||
if (token) localStorage.setItem(AUTH_TOKEN_KEY, token)
|
||||
else localStorage.removeItem(AUTH_TOKEN_KEY)
|
||||
} catch {
|
||||
// localStorage may be unavailable; in-memory token still works this session
|
||||
}
|
||||
}
|
||||
|
||||
private async request<T>(path: string, options?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${this.baseUrl}${path}`, {
|
||||
...options,
|
||||
// Send cookies for future web_password auth support
|
||||
// Cookies still work for browser access; the desktop app relies on the
|
||||
// Authorization header below.
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(this.authToken ? { Authorization: `Bearer ${this.authToken}` } : {}),
|
||||
...options?.headers,
|
||||
},
|
||||
})
|
||||
@@ -93,8 +113,16 @@ class ApiClient {
|
||||
})
|
||||
}
|
||||
|
||||
// EventSource can't set an Authorization header, so append the auth token as
|
||||
// a query param for SSE endpoints (the backend accepts it there).
|
||||
private withToken(url: string): string {
|
||||
if (!this.authToken) return url
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
return `${url}${sep}token=${encodeURIComponent(this.authToken)}`
|
||||
}
|
||||
|
||||
createSSEStream(requestId: string): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/stream?request_id=${requestId}`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/stream?request_id=${requestId}`))
|
||||
}
|
||||
|
||||
async deleteMessage(opts: {
|
||||
@@ -138,11 +166,13 @@ class ApiClient {
|
||||
|
||||
getFileUrl(previewUrl: string): string {
|
||||
if (/^https?:\/\//.test(previewUrl)) return previewUrl
|
||||
return `${this.baseUrl}${previewUrl}`
|
||||
// Served via <img src>, which can't set headers — carry the token in the
|
||||
// query so protected file endpoints load under web_password.
|
||||
return this.withToken(`${this.baseUrl}${previewUrl}`)
|
||||
}
|
||||
|
||||
getServeFileUrl(absPath: string): string {
|
||||
return `${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`
|
||||
return this.withToken(`${this.baseUrl}/api/file?path=${encodeURIComponent(absPath)}`)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------
|
||||
@@ -390,7 +420,7 @@ class ApiClient {
|
||||
// ---------------------------------------------------------
|
||||
|
||||
createLogStream(): EventSource {
|
||||
return new EventSource(`${this.baseUrl}/api/logs`)
|
||||
return new EventSource(this.withToken(`${this.baseUrl}/api/logs`))
|
||||
}
|
||||
|
||||
async getVersion(): Promise<string> {
|
||||
@@ -406,14 +436,19 @@ class ApiClient {
|
||||
return this.request('/auth/check')
|
||||
}
|
||||
|
||||
async authLogin(password: string): Promise<ApiResult> {
|
||||
return this.request('/auth/login', {
|
||||
async authLogin(password: string): Promise<ApiResult & { token?: string }> {
|
||||
const res = await this.request<ApiResult & { token?: string }>('/auth/login', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ password }),
|
||||
})
|
||||
if (res.status === 'success' && res.token) {
|
||||
this.setAuthToken(res.token)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
async authLogout(): Promise<ApiResult> {
|
||||
this.setAuthToken(null)
|
||||
return this.request('/auth/logout', { method: 'POST' })
|
||||
}
|
||||
}
|
||||
|
||||
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
72
desktop/src/renderer/src/components/LoginGate.tsx
Normal file
@@ -0,0 +1,72 @@
|
||||
import React, { useState } from 'react'
|
||||
import apiClient from '../api/client'
|
||||
import { t } from '../i18n'
|
||||
|
||||
interface LoginGateProps {
|
||||
// Called once the password is accepted (auth cookie set), so the app can
|
||||
// proceed to the main UI.
|
||||
onAuthenticated: () => void
|
||||
}
|
||||
|
||||
/**
|
||||
* Shown when the backend has a web_password set and the current session isn't
|
||||
* authenticated yet. Submitting the correct password sets an auth cookie
|
||||
* (handled by the backend), after which the app reloads its data.
|
||||
*/
|
||||
const LoginGate: React.FC<LoginGateProps> = ({ onAuthenticated }) => {
|
||||
const [password, setPassword] = useState('')
|
||||
const [submitting, setSubmitting] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const submit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!password || submitting) return
|
||||
setSubmitting(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await apiClient.authLogin(password)
|
||||
if (res.status === 'success') {
|
||||
onAuthenticated()
|
||||
} else {
|
||||
setError(t('login_error'))
|
||||
}
|
||||
} catch {
|
||||
setError(t('login_error'))
|
||||
} finally {
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-screen w-screen flex items-center justify-center bg-gray-50 dark:bg-[#111111]">
|
||||
<form onSubmit={submit} className="text-center space-y-6 max-w-md px-8 w-full">
|
||||
<img src="./logo.jpg" alt="CowAgent" className="w-16 h-16 rounded-2xl mx-auto shadow-lg shadow-primary-500/20" />
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-bold text-slate-800 dark:text-slate-100">{t('login_title')}</h1>
|
||||
<p className="text-sm text-slate-500 dark:text-slate-400">{t('login_desc')}</p>
|
||||
</div>
|
||||
<input
|
||||
type="password"
|
||||
autoFocus
|
||||
value={password}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
if (error) setError('')
|
||||
}}
|
||||
placeholder={t('login_placeholder')}
|
||||
className="w-full px-4 py-2.5 rounded-lg border border-slate-300 dark:border-slate-700 bg-white dark:bg-[#1a1a1a] text-slate-800 dark:text-slate-100 text-sm outline-none focus:border-primary-500 transition-colors"
|
||||
/>
|
||||
{error && <p className="text-sm text-red-500">{error}</p>}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting || !password}
|
||||
className="w-full inline-flex items-center justify-center gap-2 px-4 py-2.5 bg-primary-500 hover:bg-primary-600 disabled:opacity-50 disabled:cursor-not-allowed text-white rounded-lg transition-colors text-sm font-medium cursor-pointer"
|
||||
>
|
||||
{submitting ? t('login_checking') : t('login_submit')}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default LoginGate
|
||||
@@ -22,7 +22,10 @@ export function useBackend() {
|
||||
|
||||
const probeBackend = useCallback(async (port: number): Promise<boolean> => {
|
||||
try {
|
||||
const res = await fetch(`http://127.0.0.1:${port}/config`, {
|
||||
// Probe the unauthenticated health endpoint, NOT /config: once a
|
||||
// web_password is set, /config returns 401 and we'd wrongly treat the
|
||||
// (healthy) backend as unreachable, hanging on "connecting".
|
||||
const res = await fetch(`http://127.0.0.1:${port}/api/health`, {
|
||||
signal: AbortSignal.timeout(3000),
|
||||
})
|
||||
return res.ok
|
||||
|
||||
@@ -356,6 +356,13 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: '初始化失败',
|
||||
status_error_desc: '客户端初始化失败,请重试',
|
||||
status_retry: '重试',
|
||||
// login (web_password)
|
||||
login_title: '请输入访问密码',
|
||||
login_desc: '此客户端已设置访问密码,请输入以继续',
|
||||
login_placeholder: '访问密码',
|
||||
login_submit: '进入',
|
||||
login_error: '密码错误,请重试',
|
||||
login_checking: '验证中...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: '命令',
|
||||
slash_new: '新建对话',
|
||||
@@ -731,6 +738,13 @@ const translations: Record<string, Record<string, string>> = {
|
||||
status_error: 'Initialization Failed',
|
||||
status_error_desc: 'Failed to initialize the client, please retry',
|
||||
status_retry: 'Retry',
|
||||
// login (web_password)
|
||||
login_title: 'Enter access password',
|
||||
login_desc: 'This client is password-protected. Enter the password to continue.',
|
||||
login_placeholder: 'Access password',
|
||||
login_submit: 'Enter',
|
||||
login_error: 'Wrong password, please try again',
|
||||
login_checking: 'Verifying...',
|
||||
// slash command descriptions
|
||||
slash_menu_title: 'Commands',
|
||||
slash_new: 'New chat',
|
||||
|
||||
@@ -55,7 +55,9 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setMaxSteps(data.agent_max_steps ?? 20)
|
||||
setThinking(!!data.enable_thinking)
|
||||
setEvolution(!!data.self_evolution_enabled)
|
||||
setPassword(data.web_password_masked || '')
|
||||
// Prefer the real password (desktop only) so it can be edited in place;
|
||||
// fall back to the masked value for browser access.
|
||||
setPassword(data.web_password ?? data.web_password_masked ?? '')
|
||||
setPwDirty(false)
|
||||
|
||||
const ids = data.providers ? Object.keys(data.providers) : []
|
||||
@@ -130,8 +132,14 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
setTimeout(() => setAgentStatus(''), 2000)
|
||||
}
|
||||
|
||||
// Desktop returns the real password, so the field holds plaintext and can be
|
||||
// saved (including cleared) directly. Browser access only has the masked
|
||||
// value, where a masked string must never be saved as the real password.
|
||||
const hasRealPassword = config?.web_password !== undefined
|
||||
|
||||
const savePassword = async () => {
|
||||
if (!pwDirty || MASK_RE.test(password)) return
|
||||
if (!pwDirty) return
|
||||
if (!hasRealPassword && MASK_RE.test(password)) return
|
||||
try {
|
||||
await apiClient.updateConfig({ web_password: password })
|
||||
setPwStatus(password ? t('config_password_saved') : t('config_password_cleared'))
|
||||
@@ -292,10 +300,13 @@ const BasicSettings: React.FC<BasicSettingsProps> = ({ baseUrl, onLangChange, on
|
||||
value={password}
|
||||
placeholder={t('config_password_placeholder')}
|
||||
onFocus={() => {
|
||||
if (!pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
// Browser access shows a mask; clear it on focus so the user
|
||||
// types a fresh password. Desktop holds the real password and
|
||||
// must stay editable in place (cursor at the end).
|
||||
if (!hasRealPassword && !pwDirty && MASK_RE.test(password)) setPassword('')
|
||||
}}
|
||||
onBlur={() => {
|
||||
if (!pwDirty) setPassword(config?.web_password_masked || '')
|
||||
if (!hasRealPassword && !pwDirty) setPassword(config?.web_password_masked || '')
|
||||
}}
|
||||
onChange={(e) => {
|
||||
setPassword(e.target.value)
|
||||
|
||||
@@ -230,6 +230,9 @@ export interface ConfigData {
|
||||
api_keys: Record<string, string>
|
||||
providers: Record<string, ProviderMeta>
|
||||
web_password_masked?: string
|
||||
// Real password, only returned to the desktop app (trusted local machine) so
|
||||
// it can be edited in place. Undefined for browser access.
|
||||
web_password?: string
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
|
||||
Reference in New Issue
Block a user