diff --git a/electron/main/index.ts b/electron/main/index.ts index 503b94ae..8e495468 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -471,10 +471,12 @@ function handleProtocolUrl(url: string) { function processProtocolUrl(url: string) { const urlObj = new URL(url); const code = urlObj.searchParams.get('code'); + const token = urlObj.searchParams.get('token'); const share_token = urlObj.searchParams.get('share_token'); log.info('urlObj', urlObj); log.info('code', code); + log.info('token', token); log.info('share_token', share_token); if (win && !win.isDestroyed()) { @@ -489,6 +491,12 @@ function processProtocolUrl(url: string) { return; } + if (token) { + log.info('protocol token received'); + win.webContents.send('auth-token-received', token); + return; + } + if (code) { log.error('protocol code:', code); win.webContents.send('auth-code-received', code); @@ -524,6 +532,58 @@ function processQueuedProtocolUrls() { } } +// ==================== auth callback server ==================== +// Local HTTP server for receiving auth callbacks from external login (eigent.ai) +// Works in both dev and production mode, avoids eigent:// protocol issues in dev +let authCallbackServer: http.Server | null = null; +let authCallbackPort: number | null = null; + +async function startAuthCallbackServer() { + if (authCallbackServer) return authCallbackPort; + + const port = await findAvailablePort(19836, 19900); + + authCallbackServer = http.createServer((req, res) => { + const url = new URL(req.url || '', `http://localhost:${port}`); + + if (url.pathname === '/auth/callback') { + const token = url.searchParams.get('token'); + log.info('Auth callback URL:', req.url); + log.info('Auth callback token present:', !!token); + log.info('Auth callback win available:', !!win && !win.isDestroyed()); + + res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); + res.end(` + + Login Successful + +
+

Login Successful

+

You can close this tab and return to Eigent.

+
+ `); + + if (token && win && !win.isDestroyed()) { + log.info('Auth callback received token'); + win.webContents.send('auth-token-received', token); + win.show(); + win.focus(); + } + } else { + res.writeHead(404); + res.end('Not Found'); + } + }); + + authCallbackServer.listen(port); + authCallbackPort = port; + log.info(`Auth callback server started on port ${port}`); + return port; +} + // ==================== single instance lock ==================== const setupSingleInstanceLock = () => { // The lock is already acquired at module level (requestSingleInstanceLock @@ -603,6 +663,12 @@ const checkManagerInstance = (manager: any, name: string) => { }; function registerIpcHandlers() { + // ==================== auth callback ==================== + ipcMain.handle('get-auth-callback-url', async () => { + const port = await startAuthCallbackServer(); + return `http://localhost:${port}/auth/callback`; + }); + // ==================== basic info handler ==================== ipcMain.handle('get-browser-port', () => { log.info('Getting browser port'); diff --git a/server/app/controller/user/login_controller.py b/server/app/controller/user/login_controller.py index 0f925652..0d23b413 100644 --- a/server/app/controller/user/login_controller.py +++ b/server/app/controller/user/login_controller.py @@ -157,6 +157,42 @@ async def by_stack_auth( return LoginResponse(token=Auth.create_access_token(user.id), email=user.email) +@router.post("/auto-login", name="auto login for local mode") +async def auto_login(session: Session = Depends(session)) -> LoginResponse: + """ + Auto login for fully local mode (VITE_USE_LOCAL_PROXY=true). + Returns the most recently active user, or creates a default admin user if none exists. + """ + # Find the most recently active user + user = User.by( + User.status == Status.Normal, + order_by=User.updated_at.desc(), + limit=1, + s=session, + ).one_or_none() + + if not user: + # Create default admin user + with session as s: + try: + user = User( + email="admin@eigent.local", + username="admin", + nickname="Admin", + ) + s.add(user) + s.commit() + s.refresh(user) + logger.info("Default admin user created", extra={"user_id": user.id}) + except Exception as e: + s.rollback() + logger.error("Failed to create default admin user", extra={"error": str(e)}, exc_info=True) + raise UserException(code.error, _("Failed to create default user")) + + logger.info("Auto login successful", extra={"user_id": user.id, "email": user.email}) + return LoginResponse(token=Auth.create_access_token(user.id), email=user.email) + + @router.post("/register", name="register by email/password") async def register(data: RegisterIn, session: Session = Depends(session)): email = data.email diff --git a/server/app/model/user/user.py b/server/app/model/user/user.py index 4d010f2a..6e2d63c0 100644 --- a/server/app/model/user/user.py +++ b/server/app/model/user/user.py @@ -60,6 +60,7 @@ class LoginByPasswordIn(BaseModel): class LoginResponse(BaseModel): token: str email: EmailStr + redirect_url: str | None = None class UserIn(BaseModel): diff --git a/src/pages/History.tsx b/src/pages/History.tsx index dcce9184..d36ca62b 100644 --- a/src/pages/History.tsx +++ b/src/pages/History.tsx @@ -61,7 +61,7 @@ export default function Home() { const [deleteModalOpen, setDeleteModalOpen] = useState(false); const scrollContainerRef = useRef(null); const { username, email } = useAuthStore(); - const displayName = username ?? email ?? ''; + const displayName = username || email || ''; // Compute activeTab from URL, fallback to 'projects' if not in URL or invalid const activeTab = useMemo(() => { @@ -127,7 +127,7 @@ export default function Home() { cancelText={t('layout.cancel')} /> {/* welcome text */} -
+
-
+
(null); const titlebarRef = useRef(null); const [platform, setPlatform] = useState(''); - const validateEmail = (email: string) => { - const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; - return emailRegex.test(email); - }; - - const validateForm = () => { - const newErrors = { - email: '', - password: '', - }; - - if (!formData.email) { - newErrors.email = t('layout.please-enter-email-address'); - } else if (!validateEmail(formData.email)) { - newErrors.email = t('layout.please-enter-a-valid-email-address'); - } - - if (!formData.password) { - newErrors.password = t('layout.please-enter-password'); - } else if (formData.password.length < 8) { - newErrors.password = t('layout.password-must-be-at-least-8-characters'); - } - - setErrors(newErrors); - return !newErrors.email && !newErrors.password; - }; - const getLoginErrorMessage = useCallback( (data: any) => { if (!data || typeof data !== 'object' || typeof data.code !== 'number') { @@ -122,37 +89,12 @@ export default function Login() { [t] ); - const handleInputChange = (field: string, value: string) => { - setFormData((prev) => ({ - ...prev, - [field]: value, - })); - - if (errors[field as keyof typeof errors]) { - setErrors((prev) => ({ - ...prev, - [field]: '', - })); - } - - if (generalError) { - setGeneralError(''); - } - }; - - // - const handleLogin = async () => { - if (!validateForm()) { - return; - } - + // Auto login for local mode - calls /api/auto-login + const handleAutoLogin = async () => { setGeneralError(''); setIsLoading(true); try { - const data = await proxyFetchPost('/api/login', { - email: formData.email, - password: formData.password, - }); + const data = await proxyFetchPost('/api/auto-login', {}); const errorMessage = getLoginErrorMessage(data); if (errorMessage) { @@ -160,22 +102,21 @@ export default function Login() { return; } - setAuth({ email: formData.email, ...data }); - setModelType('cloud'); - // Record VITE_USE_LOCAL_PROXY value at login - const localProxyValue = import.meta.env.VITE_USE_LOCAL_PROXY || null; - setLocalProxyValue(localProxyValue); + setAuth({ email: data.email, ...data }); + setLocalProxyValue(import.meta.env.VITE_USE_LOCAL_PROXY || null); + setModelType('custom'); + setInitState('done'); + setIsFirstLaunch(false); navigate('/'); } catch (error: any) { - console.error('Login failed:', error); - setGeneralError( - t('layout.login-failed-please-check-your-email-and-password') - ); + console.error('Auto login failed:', error); + setGeneralError(t('layout.login-failed-please-try-again')); } finally { setIsLoading(false); } }; + // Hybrid/app mode: handle Stack Auth callback (reuse existing OAuth flow) const handleLoginByStack = useCallback( async (token: string) => { try { @@ -191,10 +132,8 @@ export default function Login() { setGeneralError(errorMessage); return; } - console.log('data', data); setModelType('cloud'); - setAuth({ email: formData.email, ...data }); - // Record VITE_USE_LOCAL_PROXY value at login + setAuth({ email: data.email, ...data }); const localProxyValue = import.meta.env.VITE_USE_LOCAL_PROXY || null; setLocalProxyValue(localProxyValue); navigate('/'); @@ -208,7 +147,6 @@ export default function Login() { } }, [ - formData.email, navigate, setAuth, setModelType, @@ -220,33 +158,10 @@ export default function Login() { ] ); - const handleReloadBtn = async (type: string) => { - if (!app) { - console.error('Stack app not initialized'); - return; - } - console.log('handleReloadBtn1', type); - const cookies = document.cookie.split('; '); - cookies.forEach((cookie) => { - const [name] = cookie.split('='); - if (name.startsWith('stack-oauth-outer-')) { - document.cookie = `${name}=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/`; - } - }); - console.log('handleReloadBtn2', type); - await app.signInWithOAuth(type); - }; - const handleGetToken = useCallback( async (code: string) => { const code_verifier = localStorage.getItem('stack-oauth-outer-'); const formData = new URLSearchParams(); - console.log( - 'import.meta.env.PROD', - import.meta.env.PROD - ? `${import.meta.env.VITE_BASE_URL}/api/redirect/callback` - : `${import.meta.env.VITE_PROXY_URL}/api/redirect/callback` - ); formData.append( 'redirect_uri', import.meta.env.PROD @@ -273,7 +188,7 @@ export default function Login() { body: formData, } ); - const data = await res.json(); // parse response data + const data = await res.json(); return data.access_token; } catch (error) { console.error(error); @@ -298,6 +213,44 @@ export default function Login() { [location.pathname, handleLoginByStack, handleGetToken, setIsLoading] ); + // Listen for direct token callback from Electron (eigent.ai login redirect) + useEffect(() => { + const handleTokenReceived = async (_event: any, token: string) => { + if (!token) return; + setIsLoading(true); + // Temporarily set token so proxyFetchGet can use it for auth + setAuth({ email: '', token, username: '', user_id: 0 }); + setLocalProxyValue(import.meta.env.VITE_USE_LOCAL_PROXY || null); + try { + const userInfo = await proxyFetchGet('/api/user'); + if (userInfo && userInfo.email) { + setAuth({ + token, + email: userInfo.email, + username: + userInfo.username || + userInfo.nickname || + userInfo.fullname || + userInfo.email?.split('@')[0] || + '', + user_id: + userInfo.id || JSON.parse(atob(token.split('.')[1])).id || 0, + }); + } + } catch (e) { + console.error('Failed to fetch user info:', e); + } + navigate('/'); + }; + + window.ipcRenderer?.on('auth-token-received', handleTokenReceived); + + return () => { + window.ipcRenderer?.off('auth-token-received', handleTokenReceived); + }; + }, [setAuth, setLocalProxyValue, navigate]); + + // Listen for auth code callback from Electron (Stack Auth OAuth flow) useEffect(() => { window.ipcRenderer?.on('auth-code-received', handleAuthCode); @@ -318,7 +271,6 @@ export default function Login() { // Handle before-close event for login page useEffect(() => { const handleBeforeClose = () => { - // On login page, always close directly without confirmation window.electronAPI.closeWindow(true); }; @@ -329,6 +281,91 @@ export default function Login() { }; }, []); + // Hybrid/app mode: prepare auth callback URL on mount (don't auto-open browser) + useEffect(() => { + if (IS_LOCAL_MODE) return; + + const prepareCallbackUrl = async () => { + let cbUrl: string; + if (import.meta.env.PROD) { + cbUrl = 'eigent://auth/callback'; + } else { + cbUrl = 'eigent://auth/callback'; + try { + const url = await window.ipcRenderer?.invoke('get-auth-callback-url'); + if (url) cbUrl = url; + } catch (e) { + // Fallback to eigent:// protocol + } + } + setCallbackUrl(cbUrl); + }; + + prepareCallbackUrl(); + }, []); + + // Render local mode: "Start Eigent" button only + const renderLocalMode = () => ( +
+ +
+ Eigent +
+ {generalError && ( +

+ {generalError} +

+ )} + +
+ ); + + // Render hybrid/app mode: waiting for external login callback + const renderHybridMode = () => ( +
+ +
+ {t('layout.login')} +
+ {isLoading && ( +

+ {t('layout.logging-in')}... +

+ )} + +
+ ); + return (
{/* Titlebar with drag region and window controls */} @@ -373,119 +410,7 @@ export default function Login() { backgroundPosition: 'center', }} > -
- -
-
- {t('layout.login')} -
- -
- {HAS_STACK_KEYS && ( -
- - -
- )} - {HAS_STACK_KEYS && ( -
- {t('layout.or')} -
- )} -
- {generalError && ( -

- {generalError} -

- )} -
- handleInputChange('email', e.target.value)} - state={errors.email ? 'error' : undefined} - note={errors.email} - onEnter={handleLogin} - /> - - - handleInputChange('password', e.target.value) - } - state={errors.password ? 'error' : undefined} - note={errors.password} - backIcon={} - onBackIconClick={() => setHidePassword(!hidePassword)} - onEnter={handleLogin} - /> -
-
- -
+ {IS_LOCAL_MODE ? renderLocalMode() : renderHybridMode()}
diff --git a/src/pages/SignUp.tsx b/src/pages/SignUp.tsx index 2b1194fa..e3f38d52 100644 --- a/src/pages/SignUp.tsx +++ b/src/pages/SignUp.tsx @@ -32,6 +32,7 @@ import { hasStackKeys } from '@/lib'; import { useTranslation } from 'react-i18next'; const HAS_STACK_KEYS = hasStackKeys(); +const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; let lock = false; export default function SignUp() { // Always call hooks unconditionally - React Hooks must be called in the same order @@ -40,6 +41,25 @@ export default function SignUp() { const { setAuth, initState: _initState } = useAuthStore(); const navigate = useNavigate(); const location = useLocation(); + + // Local mode: no signup needed, redirect to home + useEffect(() => { + if (IS_LOCAL_MODE) { + navigate('/', { replace: true }); + } + }, [navigate]); + + // Hybrid/app mode without Stack keys: redirect to external signup + useEffect(() => { + if (!IS_LOCAL_MODE && !HAS_STACK_KEYS) { + window.open( + 'https://www.eigent.ai/signup', + '_blank', + 'noopener,noreferrer' + ); + navigate('/login', { replace: true }); + } + }, [navigate]); const [hidePassword, setHidePassword] = useState(true); const { t } = useTranslation(); const [formData, setFormData] = useState({ diff --git a/src/routers/index.tsx b/src/routers/index.tsx index 301a8618..f7e283ea 100644 --- a/src/routers/index.tsx +++ b/src/routers/index.tsx @@ -12,6 +12,7 @@ // limitations under the License. // ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. ========= +import { proxyFetchPost } from '@/api/http'; import { useAuthStore } from '@/store/authStore'; import { lazy, useEffect, useReducer } from 'react'; import { Navigate, Outlet, Route, Routes } from 'react-router-dom'; @@ -24,6 +25,8 @@ const Home = lazy(() => import('@/pages/Home')); const History = lazy(() => import('@/pages/History')); const NotFound = lazy(() => import('@/pages/NotFound')); +const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true'; + interface AuthState { loading: boolean; isAuthenticated: boolean; @@ -61,7 +64,16 @@ const ProtectedRoute = () => { initialized: false, }); - const { token, localProxyValue, logout } = useAuthStore(); + const { + token, + localProxyValue, + logout, + setAuth, + setLocalProxyValue, + setInitState, + setIsFirstLaunch, + setModelType, + } = useAuthStore(); useEffect(() => { // Check VITE_USE_LOCAL_PROXY value on app startup if (token) { @@ -77,8 +89,38 @@ const ProtectedRoute = () => { } } + // Local mode: auto-login when no token + if (IS_LOCAL_MODE && !token) { + proxyFetchPost('/api/auto-login', {}) + .then((data) => { + if (data && data.token) { + setAuth({ email: data.email, ...data }); + setLocalProxyValue(import.meta.env.VITE_USE_LOCAL_PROXY || null); + setModelType('custom'); + setInitState('done'); + setIsFirstLaunch(false); + dispatch({ + type: 'INITIALIZE', + payload: { isAuthenticated: true }, + }); + } else { + dispatch({ + type: 'INITIALIZE', + payload: { isAuthenticated: false }, + }); + } + }) + .catch(() => { + dispatch({ + type: 'INITIALIZE', + payload: { isAuthenticated: false }, + }); + }); + return; + } + dispatch({ type: 'INITIALIZE', payload: { isAuthenticated: !!token } }); - }, [token, localProxyValue, logout]); + }, [token, localProxyValue, logout, setAuth, setLocalProxyValue]); if (state.loading || !state.initialized) { return (