feat: local auto-login + eigent.ai callback for Electron hybrid login (#1497)
Some checks are pending
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
CodeQL Advanced / Analyze (python) (push) Waiting to run
Pre-commit / pre-commit (push) Waiting to run
Test / Run Python Tests (push) Waiting to run

This commit is contained in:
Tong Chen 2026-03-16 23:03:53 +08:00 committed by GitHub
parent ea44f1e0b4
commit bebd1aafd7
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 319 additions and 229 deletions

View file

@ -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(`
<!DOCTYPE html>
<html><head><title>Login Successful</title>
<style>
body { font-family: -apple-system, system-ui, sans-serif; display: flex; justify-content: center; align-items: center; min-height: 100vh; margin: 0; background: #f4f4f9; color: #333; }
.container { padding: 40px; background: white; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.1); text-align: center; }
</style></head>
<body><div class="container">
<h1>Login Successful</h1>
<p>You can close this tab and return to Eigent.</p>
</div></body></html>
`);
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');

View file

@ -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

View file

@ -60,6 +60,7 @@ class LoginByPasswordIn(BaseModel):
class LoginResponse(BaseModel):
token: str
email: EmailStr
redirect_url: str | None = None
class UserIn(BaseModel):

View file

@ -61,7 +61,7 @@ export default function Home() {
const [deleteModalOpen, setDeleteModalOpen] = useState(false);
const scrollContainerRef = useRef<HTMLDivElement | null>(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 */}
<div className="from-surface-primary to-surface-primary px-20 pt-16 flex w-full flex-row bg-gradient-to-b">
<div className="flex w-full flex-row bg-gradient-to-b from-surface-primary to-surface-primary px-20 pt-16">
<WordCarousel
words={[`${t('layout.welcome')}, ${welcomeName} !`]}
className="text-heading-xl font-bold tracking-tight"
@ -145,10 +145,10 @@ export default function Home() {
{/* Navbar */}
{/* -top-px avoids a visible hairline: at top-0 subpixel rounding can leave a gap; */}
<div
className={`border-border-disabled bg-bg-page-default px-20 pb-4 pt-10 sticky -top-px z-20 flex flex-col items-center justify-between border-x-0 border-t-0 border-solid`}
className={`sticky -top-px z-20 flex flex-col items-center justify-between border-x-0 border-t-0 border-solid border-border-disabled bg-bg-page-default px-20 pb-4 pt-10`}
>
<div className="mx-auto flex w-full flex-row items-center justify-between">
<div className="gap-2 flex items-center">
<div className="flex items-center gap-2">
<MenuToggleGroup
type="single"
value={activeTab}

View file

@ -18,13 +18,7 @@ import { useStackApp } from '@stackframe/react';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useLocation, useNavigate } from 'react-router-dom';
import { Input } from '@/components/ui/input';
import { proxyFetchPost } from '@/api/http';
import eyeOff from '@/assets/eye-off.svg';
import eye from '@/assets/eye.svg';
import github2 from '@/assets/github2.svg';
import google from '@/assets/google.svg';
import { proxyFetchGet, proxyFetchPost } from '@/api/http';
import WindowControls from '@/components/WindowControls';
import { hasStackKeys } from '@/lib';
import { useTranslation } from 'react-i18next';
@ -33,56 +27,29 @@ import background from '@/assets/background.png';
import eigentLogo from '@/assets/logo/eigent_icon.png';
const HAS_STACK_KEYS = hasStackKeys();
const IS_LOCAL_MODE = import.meta.env.VITE_USE_LOCAL_PROXY === 'true';
let lock = false;
export default function Login() {
// Always call hooks unconditionally - React Hooks must be called in the same order
const stackApp = useStackApp();
const app = HAS_STACK_KEYS ? stackApp : null;
const { setAuth, setModelType, setLocalProxyValue } = useAuthStore();
const {
setAuth,
setModelType,
setLocalProxyValue,
setInitState,
setIsFirstLaunch,
} = useAuthStore();
const navigate = useNavigate();
const location = useLocation();
const [hidePassword, setHidePassword] = useState(true);
const { t } = useTranslation();
const [formData, setFormData] = useState({
email: '',
password: '',
});
const [errors, setErrors] = useState({
email: '',
password: '',
});
const [isLoading, setIsLoading] = useState(false);
const [generalError, setGeneralError] = useState('');
const [callbackUrl, setCallbackUrl] = useState<string | null>(null);
const titlebarRef = useRef<HTMLDivElement>(null);
const [platform, setPlatform] = useState<string>('');
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 = () => (
<div className="relative flex w-80 flex-1 flex-col items-center justify-center pt-8">
<img
src={eigentLogo}
className="absolute left-1/2 top-10 h-16 w-16 -translate-x-1/2"
/>
<div className="mb-8 text-heading-lg font-bold text-text-heading">
Eigent
</div>
{generalError && (
<p className="mb-4 mt-1 text-label-md text-text-cuation">
{generalError}
</p>
)}
<Button
onClick={handleAutoLogin}
size="lg"
variant="primary"
className="w-full rounded-full"
disabled={isLoading}
>
<span className="flex-1">
{isLoading ? t('layout.logging-in') : 'Start Eigent'}
</span>
</Button>
</div>
);
// Render hybrid/app mode: waiting for external login callback
const renderHybridMode = () => (
<div className="relative flex w-80 flex-1 flex-col items-center justify-center pt-8">
<img
src={eigentLogo}
className="absolute left-1/2 top-10 h-16 w-16 -translate-x-1/2"
/>
<div className="mb-4 text-heading-lg font-bold text-text-heading">
{t('layout.login')}
</div>
{isLoading && (
<p className="mb-6 text-center text-label-md text-text-secondary">
{t('layout.logging-in')}...
</p>
)}
<Button
onClick={() => {
setIsLoading(true);
window.open(
`https://www.eigent.ai/signin?callbackUrl=${encodeURIComponent(callbackUrl || 'eigent://auth/callback')}`,
'_blank',
'noopener,noreferrer'
);
}}
size="lg"
variant="primary"
className="w-full rounded-full"
>
<span className="flex-1">{t('layout.log-in')}</span>
</Button>
</div>
);
return (
<div className="relative flex h-full flex-col overflow-hidden">
{/* Titlebar with drag region and window controls */}
@ -373,119 +410,7 @@ export default function Login() {
backgroundPosition: 'center',
}}
>
<div className="relative flex w-80 flex-1 flex-col items-center justify-center pt-8">
<img
src={eigentLogo}
className="absolute left-1/2 top-10 h-16 w-16 -translate-x-1/2"
/>
<div className="mb-4 flex items-end justify-between self-stretch">
<div className="text-heading-lg font-bold text-text-heading">
{t('layout.login')}
</div>
<Button
variant="ghost"
size="sm"
onClick={() => {
if (import.meta.env.VITE_USE_LOCAL_PROXY === 'true') {
navigate('/signup');
} else {
window.open(
'https://www.eigent.ai/signup',
'_blank',
'noopener,noreferrer'
);
}
}}
>
{t('layout.sign-up')}
</Button>
</div>
{HAS_STACK_KEYS && (
<div className="w-full pt-6">
<Button
variant="primary"
size="lg"
onClick={() => handleReloadBtn('google')}
className="mb-4 w-full justify-center rounded-[24px] text-center font-inter text-[15px] font-bold leading-[22px] text-[#F5F5F5] transition-all duration-300 ease-in-out"
disabled={isLoading}
>
<img src={google} className="h-5 w-5" />
<span className="ml-2">
{t('layout.continue-with-google-login')}
</span>
</Button>
<Button
variant="primary"
size="lg"
onClick={() => handleReloadBtn('github')}
className="mb-4 w-full justify-center rounded-[24px] text-center font-inter text-[15px] font-bold leading-[22px] text-[#F5F5F5] transition-all duration-300 ease-in-out"
disabled={isLoading}
>
<img src={github2} className="h-5 w-5" />
<span className="ml-2">
{t('layout.continue-with-github-login')}
</span>
</Button>
</div>
)}
{HAS_STACK_KEYS && (
<div className="mb-6 mt-2 w-full text-center font-inter text-[15px] font-medium leading-[22px] text-[#222]">
{t('layout.or')}
</div>
)}
<div className="flex w-full flex-col gap-4">
{generalError && (
<p className="mb-4 mt-1 text-label-md text-text-cuation">
{generalError}
</p>
)}
<div className="relative mb-4 flex w-full flex-col gap-4">
<Input
id="email"
type="email"
size="default"
title={t('layout.email')}
placeholder={t('layout.enter-your-email')}
required
value={formData.email}
onChange={(e) => handleInputChange('email', e.target.value)}
state={errors.email ? 'error' : undefined}
note={errors.email}
onEnter={handleLogin}
/>
<Input
id="password"
title={t('layout.password')}
size="default"
type={hidePassword ? 'password' : 'text'}
required
placeholder={t('layout.enter-your-password')}
value={formData.password}
onChange={(e) =>
handleInputChange('password', e.target.value)
}
state={errors.password ? 'error' : undefined}
note={errors.password}
backIcon={<img src={hidePassword ? eye : eyeOff} />}
onBackIconClick={() => setHidePassword(!hidePassword)}
onEnter={handleLogin}
/>
</div>
</div>
<Button
onClick={handleLogin}
size="md"
variant="primary"
type="submit"
className="w-full rounded-full"
disabled={isLoading}
>
<span className="flex-1">
{isLoading ? t('layout.logging-in') : t('layout.log-in')}
</span>
</Button>
</div>
{IS_LOCAL_MODE ? renderLocalMode() : renderHybridMode()}
</div>
</div>
</div>

View file

@ -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({

View file

@ -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 (