Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/i18n/locales/en-us/setting.json
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@

"network-proxy": "Network Proxy",
"network-proxy-description": "Configure a proxy server for network requests. This is useful if you need to access external APIs through a proxy.",
"network-proxy-helper": "Leave empty to disable the proxy. When set, all outbound HTTP requests from Eigent will respect this proxy configuration where supported.",
"proxy-placeholder": "http://127.0.0.1:7890",
"proxy-saved-restart-required": "Proxy configuration saved. Restart the app to apply changes.",
"proxy-save-failed": "Failed to save proxy configuration.",
Expand Down
16 changes: 14 additions & 2 deletions src/pages/Setting/General.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,8 @@ export default function SettingGeneral() {
const [proxyUrl, setProxyUrl] = useState('');
const [isProxySaving, setIsProxySaving] = useState(false);
const [proxyNeedsRestart, setProxyNeedsRestart] = useState(false);
const [initialProxyUrl, setInitialProxyUrl] = useState<string | null>(null);
const hasProxyChanged = proxyUrl.trim() !== (initialProxyUrl ?? '').trim();

useEffect(() => {
const platform = window.electronAPI.getPlatform();
Expand Down Expand Up @@ -165,6 +167,9 @@ export default function SettingGeneral() {
const result = await window.electronAPI.readGlobalEnv('HTTP_PROXY');
if (result?.value) {
setProxyUrl(result.value);
setInitialProxyUrl(result.value);
} else {
setInitialProxyUrl('');
}
} catch (_error) {
console.log('No proxy configured');
Expand Down Expand Up @@ -219,6 +224,7 @@ export default function SettingGeneral() {
);
if (!result?.success) throw new Error('envRemove returned no success');
}
setInitialProxyUrl(trimmed);
setProxyNeedsRestart(true);
toast.success(t('setting.proxy-saved-restart-required'));
} catch (error) {
Expand Down Expand Up @@ -358,9 +364,12 @@ export default function SettingGeneral() {
<div className="text-body-base font-bold text-text-heading">
{t('setting.network-proxy')}
</div>
<div className="mb-4 text-sm leading-13 text-text-secondary">
<div className="mb-1 text-sm leading-13 text-text-secondary">
{t('setting.network-proxy-description')}
</div>
<div className="mb-3 text-xs leading-13 text-text-secondary">
{t('setting.network-proxy-helper')}
</div>
</div>
<Input
placeholder={t('setting.proxy-placeholder')}
Expand All @@ -383,7 +392,10 @@ export default function SettingGeneral() {
? () => window.electronAPI?.restartApp()
: handleSaveProxy
}
disabled={!proxyNeedsRestart && isProxySaving}
disabled={
(!proxyNeedsRestart && isProxySaving) ||
(!proxyNeedsRestart && !hasProxyChanged)
}
>
{proxyNeedsRestart
? t('setting.restart-to-apply')
Expand Down
64 changes: 54 additions & 10 deletions src/pages/Setting/Privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,24 +18,60 @@ import { Switch } from '@/components/ui/switch';
import { ChevronDown } from 'lucide-react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { toast } from 'sonner';
export default function SettingPrivacy() {
const [helpImprove, setHelpImprove] = useState(false);
const [isLoading, setIsLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const { t } = useTranslation();
const [isHowWeHandleOpen, setIsHowWeHandleOpen] = useState(false);

useEffect(() => {
proxyFetchGet('/api/v1/user/privacy')
.then((res) => {
setHelpImprove(res.help_improve || false);
})
.catch((err) => console.error('Failed to fetch settings:', err));
}, []);
let isCancelled = false;

const handleToggleHelpImprove = (checked: boolean) => {
const loadPrivacySettings = async () => {
try {
const res = await proxyFetchGet('/api/user/privacy');
if (!isCancelled) {
setHelpImprove(Boolean(res?.help_improve));
}
} catch (err) {
if (!isCancelled) {
// Log to console for debugging while keeping user feedback localized
// eslint-disable-next-line no-console
console.error('Failed to fetch settings:', err);
toast.error(t('setting.load-failed'));
}
} finally {
if (!isCancelled) {
setIsLoading(false);
}
}
};

void loadPrivacySettings();

return () => {
isCancelled = true;
};
}, [t]);

const handleToggleHelpImprove = async (checked: boolean) => {
// Optimistically update UI but revert on failure
const previousValue = helpImprove;
setHelpImprove(checked);
proxyFetchPut('/api/v1/user/privacy', { help_improve: checked }).catch(
(err) => console.error('Failed to update settings:', err)
);
setIsSaving(true);

try {
await proxyFetchPut('/api/user/privacy', { help_improve: checked });
} catch (err) {
// eslint-disable-next-line no-console
console.error('Failed to update settings:', err);
setHelpImprove(previousValue);
toast.error(t('setting.save-failed'));
} finally {
setIsSaving(false);
}
};

return (
Expand Down Expand Up @@ -121,11 +157,19 @@ export default function SettingPrivacy() {
<div className="text-body-sm font-normal text-text-body">
{t('setting.help-improve-eigent-description')}
</div>
{!isLoading && (
<div className="text-body-xs text-text-secondary">
{helpImprove
? t('setting.enabled')
: t('setting.disabled')}
</div>
)}
</div>
<div className="flex items-center justify-center">
<Switch
checked={helpImprove}
onCheckedChange={handleToggleHelpImprove}
disabled={isLoading || isSaving}
/>
</div>
</div>
Expand Down
228 changes: 228 additions & 0 deletions test/unit/pages/Setting/GeneralProxy.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,228 @@
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
// ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========

import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';

import SettingGeneral from '@/pages/Setting/General';

vi.mock('react-i18next', () => ({
Trans: ({ children }: { children: React.ReactNode }) => children,
useTranslation: () => ({
t: (key: string) => key,
}),
}));

const mockClearTasks = vi.fn();

vi.mock('@/hooks/useChatStoreAdapter', () => ({
default: () => ({
chatStore: {
clearTasks: mockClearTasks,
},
}),
}));

const mockResetInstallation = vi.fn();
const mockSetNeedsBackendRestart = vi.fn();

vi.mock('@/store/installationStore', async () => {
const actual =
await vi.importActual<typeof import('@/store/installationStore')>(
'@/store/installationStore'
);
return {
...actual,
useInstallationStore: (selector: any) =>
selector({
reset: mockResetInstallation,
setNeedsBackendRestart: mockSetNeedsBackendRestart,
}),
};
});

const mockLogout = vi.fn();

vi.mock('@/store/authStore', async () => {
const actual = await vi.importActual<typeof import('@/store/authStore')>(
'@/store/authStore'
);
return {
...actual,
useAuthStore: () => ({
email: 'test@example.com',
appearance: 'dark',
language: 'system',
setAppearance: vi.fn(),
setLanguage: vi.fn(),
logout: mockLogout,
}),
getAuthStore: () => ({
token: 'token',
}),
};
});

const toastErrorMock = vi.fn();
const toastSuccessMock = vi.fn();

vi.mock('sonner', () => ({
toast: {
error: (msg: string) => toastErrorMock(msg),
success: (msg: string) => toastSuccessMock(msg),
},
}));

const navigateMock = vi.fn();

vi.mock('react-router-dom', async () => {
const actual = await vi.importActual<typeof import('react-router-dom')>(
'react-router-dom'
);
return {
...actual,
useNavigate: () => navigateMock,
};
});

describe('SettingGeneral Network Proxy', () => {
beforeEach(() => {
vi.clearAllMocks();

(window as any).electronAPI = {
getPlatform: () => 'linux',
readGlobalEnv: vi.fn().mockResolvedValue({ value: 'http://proxy:8080' }),
envWrite: vi.fn().mockResolvedValue({ success: true }),
envRemove: vi.fn().mockResolvedValue({ success: true }),
restartApp: vi.fn(),
};
});

it('loads existing proxy value and disables save button until changed', async () => {
render(<SettingGeneral />);

const input = await screen.findByPlaceholderText(
'setting.proxy-placeholder'
);
expect((input as HTMLInputElement).value).toBe('http://proxy:8080');

const button = screen.getByRole('button', { name: 'setting.save' });
expect(button).toBeDisabled();

await userEvent.clear(input);
await userEvent.type(input, 'http://proxy:9090');

expect(button).not.toBeDisabled();
});

it('validates proxy URL and shows error for invalid protocol', async () => {
render(<SettingGeneral />);

const input = await screen.findByPlaceholderText(
'setting.proxy-placeholder'
);

await userEvent.clear(input);
await userEvent.type(input, 'ftp://invalid-proxy');

const button = screen.getByRole('button', { name: 'setting.save' });
await userEvent.click(button);

await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith('setting.proxy-invalid-url');
});
});

it('saves proxy URL and requires restart', async () => {
render(<SettingGeneral />);

const input = await screen.findByPlaceholderText(
'setting.proxy-placeholder'
);

await userEvent.clear(input);
await userEvent.type(input, 'http://proxy:9090');

const button = screen.getByRole('button', { name: 'setting.save' });
await userEvent.click(button);

await waitFor(() => {
expect((window as any).electronAPI.envWrite).toHaveBeenCalledWith(
'test@example.com',
{
key: 'HTTP_PROXY',
value: 'http://proxy:9090',
}
);
expect(toastSuccessMock).toHaveBeenCalledWith(
'setting.proxy-saved-restart-required'
);
});

// Button should now show restart label and trigger restart
const restartButton = screen.getByRole('button', {
name: 'setting.restart-to-apply',
});
await userEvent.click(restartButton);

expect((window as any).electronAPI.restartApp).toHaveBeenCalled();
});
it('removes proxy when input cleared', async () => {
// Start with existing value
render(<SettingGeneral />);

const input = await screen.findByPlaceholderText(
'setting.proxy-placeholder'
);

await userEvent.clear(input);

const button = screen.getByRole('button', { name: 'setting.save' });
await userEvent.click(button);

await waitFor(() => {
expect((window as any).electronAPI.envRemove).toHaveBeenCalledWith(
'test@example.com',
'HTTP_PROXY'
);
expect(toastSuccessMock).toHaveBeenCalledWith(
'setting.proxy-saved-restart-required'
);
});
});

it('shows error when electron env APIs are missing', async () => {
(window as any).electronAPI = {
getPlatform: () => 'linux',
readGlobalEnv: vi.fn().mockResolvedValue({ value: '' }),
// envWrite/envRemove intentionally omitted
};

render(<SettingGeneral />);

const input = await screen.findByPlaceholderText(
'setting.proxy-placeholder'
);
await userEvent.type(input, 'http://proxy:8080');

const button = screen.getByRole('button', { name: 'setting.save' });
await userEvent.click(button);

await waitFor(() => {
expect(toastErrorMock).toHaveBeenCalledWith('setting.proxy-save-failed');
});
});
});

Loading
Loading