Feat: Allow HTTP proxy authentication inputs (#4479)

* sub dir

* setting proxy

* test useConfigurations

* fix lint

* test

* test 2

* update check
This commit is contained in:
Doan Bui 2025-01-24 09:31:26 +07:00 committed by GitHub
parent 8c6e6edd36
commit 83550cd0d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 806 additions and 100 deletions

View File

@ -5,8 +5,15 @@ const EXPERIMENTAL_FEATURE = 'experimentalFeature'
const PROXY_FEATURE_ENABLED = 'proxyFeatureEnabled'
const VULKAN_ENABLED = 'vulkanEnabled'
const IGNORE_SSL = 'ignoreSSLFeature'
const VERIFY_PROXY_SSL = 'verifyProxySSL'
const VERIFY_PROXY_HOST_SSL = 'verifyProxyHostSSL'
const VERIFY_PEER_SSL = 'verifyPeerSSL'
const VERIFY_HOST_SSL = 'verifyHostSSL'
const HTTPS_PROXY_FEATURE = 'httpsProxyFeature'
const PROXY_USERNAME = 'proxyUsername'
const PROXY_PASSWORD = 'proxyPassword'
const QUICK_ASK_ENABLED = 'quickAskEnabled'
const NO_PROXY = 'noProxy'
export const janDataFolderPathAtom = atom('')
@ -27,9 +34,56 @@ export const proxyAtom = atomWithStorage(HTTPS_PROXY_FEATURE, '', undefined, {
getOnInit: true,
})
export const proxyUsernameAtom = atomWithStorage(
PROXY_USERNAME,
'',
undefined,
{ getOnInit: true }
)
export const proxyPasswordAtom = atomWithStorage(
PROXY_PASSWORD,
'',
undefined,
{ getOnInit: true }
)
export const ignoreSslAtom = atomWithStorage(IGNORE_SSL, false, undefined, {
getOnInit: true,
})
export const noProxyAtom = atomWithStorage(NO_PROXY, '', undefined, {
getOnInit: false,
})
export const verifyProxySslAtom = atomWithStorage(
VERIFY_PROXY_SSL,
false,
undefined,
{ getOnInit: true }
)
export const verifyProxyHostSslAtom = atomWithStorage(
VERIFY_PROXY_HOST_SSL,
false,
undefined,
{ getOnInit: true }
)
export const verifyPeerSslAtom = atomWithStorage(
VERIFY_PEER_SSL,
false,
undefined,
{ getOnInit: true }
)
export const verifyHostSslAtom = atomWithStorage(
VERIFY_HOST_SSL,
false,
undefined,
{ getOnInit: true }
)
export const vulkanEnabledAtom = atomWithStorage(
VULKAN_ENABLED,
false,

View File

@ -0,0 +1,137 @@
import { renderHook, act } from '@testing-library/react'
import { useConfigurations } from './useConfigurations'
import { useAtomValue } from 'jotai'
import { extensionManager } from '@/extension'
// Mock dependencies
jest.mock('jotai', () => {
const originalModule = jest.requireActual('jotai')
return {
...originalModule,
useAtomValue: jest.fn(),
}
})
jest.mock('@/extension', () => ({
extensionManager: {
get: jest.fn(),
},
}))
describe('useConfigurations', () => {
beforeEach(() => {
jest.clearAllMocks()
})
it('should call configurePullOptions with correct proxy settings when proxy is enabled', () => {
// Explicitly set mock return values for each call
(useAtomValue as jest.Mock)
.mockReturnValueOnce(true) // proxyEnabled
.mockReturnValueOnce('http://proxy.example.com') // proxyUrl
.mockReturnValueOnce('') // proxyIgnoreSSL
.mockReturnValueOnce(true) // verifyProxySSL
.mockReturnValueOnce(true) // verifyProxyHostSSL
.mockReturnValueOnce(true) // verifyPeerSSL
.mockReturnValueOnce(true) // verifyHostSSL
.mockReturnValueOnce('') // noProxy
.mockReturnValueOnce('username') // proxyUsername
.mockReturnValueOnce('password') // proxyPassword
const mockConfigurePullOptions = jest.fn()
;(extensionManager.get as jest.Mock).mockReturnValue({
configurePullOptions: mockConfigurePullOptions,
})
const { result } = renderHook(() => useConfigurations())
act(() => {
result.current.configurePullOptions()
})
expect(mockConfigurePullOptions).toHaveBeenCalledWith({
proxy_username: 'username',
proxy_password: 'password',
proxy_url: 'http://proxy.example.com',
verify_proxy_ssl: true,
verify_proxy_host_ssl: true,
verify_peer_ssl: true,
verify_host_ssl: true,
no_proxy: '',
})
})
it('should call configurePullOptions with empty proxy settings when proxy is disabled', () => {
// Mock atom values
;(useAtomValue as jest.Mock)
.mockReturnValueOnce(false) // proxyEnabled
.mockReturnValueOnce('') // proxyUrl
.mockReturnValueOnce(false) // proxyIgnoreSSL
.mockReturnValueOnce('') // noProxy
.mockReturnValueOnce('') // proxyUsername
.mockReturnValueOnce('') // proxyPassword
.mockReturnValueOnce(false) // verifyProxySSL
.mockReturnValueOnce(false) // verifyProxyHostSSL
.mockReturnValueOnce(false) // verifyPeerSSL
.mockReturnValueOnce(false) // verifyHostSSL
const mockConfigurePullOptions = jest.fn()
;(extensionManager.get as jest.Mock).mockReturnValue({
configurePullOptions: mockConfigurePullOptions,
})
const { result } = renderHook(() => useConfigurations())
act(() => {
result.current.configurePullOptions()
})
expect(mockConfigurePullOptions).toHaveBeenCalledWith({
proxy_username: '',
proxy_password: '',
proxy_url: '',
verify_proxy_ssl: false,
verify_proxy_host_ssl: false,
verify_peer_ssl: false,
verify_host_ssl: false,
no_proxy: '',
})
})
it('should set all verify SSL to false when proxyIgnoreSSL is true', () => {
// Mock atom values
;(useAtomValue as jest.Mock)
.mockReturnValueOnce(true) // proxyEnabled
.mockReturnValueOnce('http://proxy.example.com') // proxyUrl
.mockReturnValueOnce(true) // proxyIgnoreSSL
.mockReturnValueOnce(true) // verifyProxySSL
.mockReturnValueOnce(true) // verifyProxyHostSSL
.mockReturnValueOnce(true) // verifyPeerSSL
.mockReturnValueOnce(true) // verifyHostSSL
.mockReturnValueOnce('') // noProxy
.mockReturnValueOnce('username') // proxyUsername
.mockReturnValueOnce('password') // proxyPassword
const mockConfigurePullOptions = jest.fn()
;(extensionManager.get as jest.Mock).mockReturnValue({
configurePullOptions: mockConfigurePullOptions,
})
const { result } = renderHook(() => useConfigurations())
act(() => {
result.current.configurePullOptions()
})
expect(mockConfigurePullOptions).toHaveBeenCalledWith({
proxy_username: 'username',
proxy_password: 'password',
proxy_url: 'http://proxy.example.com',
verify_proxy_ssl: false,
verify_proxy_host_ssl: false,
verify_peer_ssl: false,
verify_host_ssl: false,
no_proxy: '',
})
})
})

View File

@ -6,14 +6,28 @@ import { useAtomValue } from 'jotai'
import { extensionManager } from '@/extension'
import {
ignoreSslAtom,
noProxyAtom,
proxyAtom,
proxyEnabledAtom,
proxyPasswordAtom,
proxyUsernameAtom,
verifyHostSslAtom,
verifyPeerSslAtom,
verifyProxyHostSslAtom,
verifyProxySslAtom,
} from '@/helpers/atoms/AppConfig.atom'
export const useConfigurations = () => {
const proxyEnabled = useAtomValue(proxyEnabledAtom)
const proxyUrl = useAtomValue(proxyAtom)
const proxyIgnoreSSL = useAtomValue(ignoreSslAtom)
const verifyProxySSL = useAtomValue(verifyProxySslAtom)
const verifyProxyHostSSL = useAtomValue(verifyProxyHostSslAtom)
const verifyPeerSSL = useAtomValue(verifyPeerSslAtom)
const verifyHostSSL = useAtomValue(verifyHostSslAtom)
const noProxy = useAtomValue(noProxyAtom)
const proxyUsername = useAtomValue(proxyUsernameAtom)
const proxyPassword = useAtomValue(proxyPasswordAtom)
const configurePullOptions = useCallback(() => {
extensionManager
@ -21,20 +35,45 @@ export const useConfigurations = () => {
?.configurePullOptions(
proxyEnabled
? {
proxy_username: proxyUsername,
proxy_password: proxyPassword,
proxy_url: proxyUrl,
verify_peer_ssl: !proxyIgnoreSSL,
verify_proxy_ssl: proxyIgnoreSSL ? false : verifyProxySSL,
verify_proxy_host_ssl: proxyIgnoreSSL
? false
: verifyProxyHostSSL,
verify_peer_ssl: proxyIgnoreSSL ? false : verifyPeerSSL,
verify_host_ssl: proxyIgnoreSSL ? false : verifyHostSSL,
no_proxy: noProxy,
}
: {
proxy_username: '',
proxy_password: '',
proxy_url: '',
verify_proxy_ssl: false,
verify_proxy_host_ssl: false,
verify_peer_ssl: false,
verify_host_ssl: false,
no_proxy: '',
}
)
}, [proxyEnabled, proxyUrl, proxyIgnoreSSL])
}, [
proxyEnabled,
proxyUrl,
proxyIgnoreSSL,
noProxy,
proxyUsername,
proxyPassword,
verifyProxySSL,
verifyProxyHostSSL,
verifyPeerSSL,
verifyHostSSL,
])
useEffect(() => {
configurePullOptions()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
}, [configurePullOptions])
return {
configurePullOptions,

View File

@ -0,0 +1,147 @@
/**
* @jest-environment jsdom
*/
import React from 'react'
import { render, screen, fireEvent, waitFor } from '@testing-library/react'
import '@testing-library/jest-dom'
import ProxySettings from '.'
// Mock ResizeObserver
class ResizeObserverMock {
observe() {}
unobserve() {}
disconnect() {}
}
global.ResizeObserver = ResizeObserverMock as any
// Mock global window.core
global.window.core = {
api: {
getAppConfigurations: () => jest.fn(),
updateAppConfiguration: () => jest.fn(),
relaunch: () => jest.fn(),
},
}
// Mock dependencies
jest.mock('@/hooks/useConfigurations', () => ({
useConfigurations: () => ({
configurePullOptions: jest.fn(),
}),
}))
jest.mock('jotai', () => {
const originalModule = jest.requireActual('jotai')
return {
...originalModule,
useAtom: jest.fn().mockImplementation((atom) => {
switch (atom) {
case 'proxyEnabledAtom':
return [true, jest.fn()]
case 'proxyAtom':
return ['', jest.fn()]
case 'proxyUsernameAtom':
return ['', jest.fn()]
case 'proxyPasswordAtom':
return ['', jest.fn()]
case 'ignoreSslAtom':
return [false, jest.fn()]
case 'verifyProxySslAtom':
return [true, jest.fn()]
case 'verifyProxyHostSslAtom':
return [true, jest.fn()]
case 'verifyPeerSslAtom':
return [true, jest.fn()]
case 'verifyHostSslAtom':
return [true, jest.fn()]
case 'noProxyAtom':
return ['localhost', jest.fn()]
default:
return [null, jest.fn()]
}
}),
}
})
describe('ProxySettings', () => {
const mockOnBack = jest.fn()
beforeEach(() => {
jest.clearAllMocks()
})
it('renders the component', async () => {
render(<ProxySettings onBack={mockOnBack} />)
await waitFor(() => {
expect(screen.getByText('HTTPS Proxy')).toBeInTheDocument()
expect(screen.getByText('Proxy URL')).toBeInTheDocument()
expect(screen.getByText('Authentication')).toBeInTheDocument()
expect(screen.getByText('No Proxy')).toBeInTheDocument()
expect(screen.getByText('SSL Verification')).toBeInTheDocument()
})
})
it('handles back navigation', async () => {
render(<ProxySettings onBack={mockOnBack} />)
const backButton = screen.getByText('Advanced Settings')
fireEvent.click(backButton)
expect(mockOnBack).toHaveBeenCalled()
})
it('toggles password visibility', () => {
render(<ProxySettings onBack={mockOnBack} />)
const passwordVisibilityToggle = screen.getByTestId('password-visibility-toggle')
const passwordInput = screen.getByTestId('proxy-password')
expect(passwordInput).toHaveAttribute('type', 'password')
fireEvent.click(passwordVisibilityToggle)
expect(passwordInput).toHaveAttribute('type', 'text')
fireEvent.click(passwordVisibilityToggle)
expect(passwordInput).toHaveAttribute('type', 'password')
})
it('allows clearing input fields', async () => {
render(<ProxySettings onBack={mockOnBack} />)
// Test clearing proxy URL
const proxyInput = screen.getByTestId('proxy-input')
fireEvent.change(proxyInput, { target: { value: 'http://test.proxy' } })
const clearProxyButton = screen.getByTestId('clear-proxy-button')
fireEvent.click(clearProxyButton)
expect(proxyInput).toHaveValue('')
// Test clearing username
const usernameInput = screen.getByTestId('proxy-username')
fireEvent.change(usernameInput, { target: { value: 'testuser' } })
// Test clearing password
const passwordInput = screen.getByTestId('proxy-password')
fireEvent.change(passwordInput, { target: { value: 'testpassword' } })
})
it('renders SSL verification switches', async () => {
render(<ProxySettings onBack={mockOnBack} />)
const sslSwitches = [
'Ignore SSL certificates',
'Verify Proxy SSL',
'Verify Proxy Host SSL',
'Verify Peer SSL',
'Verify Host SSL'
]
sslSwitches.forEach(switchText => {
const switchElement = screen.getByText(switchText)
expect(switchElement).toBeInTheDocument()
})
})
})

View File

@ -0,0 +1,394 @@
import { useCallback, useState } from 'react'
import { Input, ScrollArea, Switch } from '@janhq/joi'
import { useAtom } from 'jotai'
import { EyeIcon, EyeOffIcon, XIcon, ArrowLeftIcon } from 'lucide-react'
import { useDebouncedCallback } from 'use-debounce'
import { useConfigurations } from '@/hooks/useConfigurations'
import {
ignoreSslAtom,
proxyAtom,
proxyEnabledAtom,
verifyProxySslAtom,
verifyProxyHostSslAtom,
verifyPeerSslAtom,
verifyHostSslAtom,
noProxyAtom,
proxyUsernameAtom,
proxyPasswordAtom,
} from '@/helpers/atoms/AppConfig.atom'
const ProxySettings = ({ onBack }: { onBack: () => void }) => {
const [proxyEnabled] = useAtom(proxyEnabledAtom)
const [proxy, setProxy] = useAtom(proxyAtom)
const [noProxy, setNoProxy] = useAtom(noProxyAtom)
const [partialProxy, setPartialProxy] = useState<string>(proxy)
const [proxyUsername, setProxyUsername] = useAtom(proxyUsernameAtom)
const [proxyPassword, setProxyPassword] = useAtom(proxyPasswordAtom)
const [proxyPartialPassword, setProxyPartialPassword] =
useState<string>(proxyPassword)
const [proxyPartialUsername, setProxyPartialUsername] =
useState<string>(proxyUsername)
const { configurePullOptions } = useConfigurations()
const [ignoreSSL, setIgnoreSSL] = useAtom(ignoreSslAtom)
const [verifyProxySSL, setVerifyProxySSL] = useAtom(verifyProxySslAtom)
const [verifyProxyHostSSL, setVerifyProxyHostSSL] = useAtom(
verifyProxyHostSslAtom
)
const [verifyPeerSSL, setVerifyPeerSSL] = useAtom(verifyPeerSslAtom)
const [verifyHostSSL, setVerifyHostSSL] = useAtom(verifyHostSslAtom)
const [showPassword, setShowPassword] = useState(false)
const updatePullOptions = useDebouncedCallback(
() => configurePullOptions(),
1000
)
const onProxyChange = useDebouncedCallback((value: string) => {
if (value.trim().startsWith('http')) {
setProxy(value.trim())
updatePullOptions()
} else {
setProxy('')
updatePullOptions()
}
}, 1000)
const onProxyUsernameChange = useDebouncedCallback((value: string) => {
setProxyUsername(value)
updatePullOptions()
}, 1000)
const onProxyPasswordChange = useDebouncedCallback((value: string) => {
setProxyPassword(value)
updatePullOptions()
}, 1000)
const handleProxyInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value || ''
setPartialProxy(value)
onProxyChange(value)
},
[setPartialProxy, onProxyChange]
)
const handleProxyUsernameInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value || ''
setProxyPartialUsername(value)
onProxyUsernameChange(value)
},
[setProxyPartialUsername, onProxyUsernameChange]
)
const handleProxyPasswordInputChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value || ''
setProxyPartialPassword(value)
onProxyPasswordChange(value)
},
[setProxyPartialPassword, onProxyPasswordChange]
)
const onNoProxyChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const listNoProxy = e.target.value || ''
const listNoProxyTrim = listNoProxy.split(',').map((item) => item.trim())
setNoProxy(listNoProxyTrim.join(','))
updatePullOptions()
},
[setNoProxy, updatePullOptions]
)
return (
<ScrollArea className="h-full w-full">
{/* Header */}
<div className="sticky top-0 z-10 flex h-12 items-center border-b border-[hsla(var(--app-border))] bg-[hsla(var(--app-bg))] px-4">
<div className="flex items-center gap-2">
<button
onClick={onBack}
className="flex items-center gap-1 text-sm text-[hsla(var(--text-secondary))] hover:text-[hsla(var(--text-primary))]"
>
<ArrowLeftIcon size={16} />
<span>Advanced Settings</span>
</button>
<span className="text-sm text-[hsla(var(--text-secondary))]">/</span>
<span className="text-sm">
<strong>HTTPS Proxy</strong>
</span>
</div>
</div>
{/* Content */}
<div className="p-4">
<div className="space-y-4">
<div className="space-y-1">
<h2 className="text-lg font-semibold">Proxy Configuration</h2>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="w-full space-y-1">
<label className="text-sm font-medium">Proxy URL</label>
<p className="text-xs text-[hsla(var(--text-secondary))]">
URL and port of your proxy server.
</p>
</div>
<div className="flex w-full flex-shrink-0 flex-col items-end gap-2 pr-1 sm:w-1/2">
<div className="w-full">
<Input
data-testid="proxy-input"
placeholder="http://<user>:<password>@<domain or IP>:<port>"
value={partialProxy}
onChange={handleProxyInputChange}
suffixIcon={
<div className="flex items-center gap-1">
{partialProxy && (
<button
type="button"
data-testid="clear-proxy-button"
onClick={() => {
setPartialProxy('')
setProxy('')
}}
className="p-1 hover:text-[hsla(var(--text-primary))]"
>
<XIcon size={14} />
</button>
)}
</div>
}
/>
</div>
</div>
</div>
</div>
</div>
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<label className="text-sm font-medium">Authentication</label>
<p className="text-xs text-[hsla(var(--text-secondary))]">
Credentials for your proxy server (if required).
</p>
</div>
<div className="w-1/2 space-y-2">
<Input
data-testid="proxy-username"
placeholder="Username"
value={proxyPartialUsername}
onChange={handleProxyUsernameInputChange}
suffixIcon={
<div className="flex items-center gap-1">
{proxyUsername && (
<button
type="button"
data-testid="clear-username-button"
onClick={() => setProxyUsername('')}
className="p-1 hover:text-[hsla(var(--text-primary))]"
>
<XIcon size={14} />
</button>
)}
</div>
}
/>
<Input
data-testid="proxy-password"
type={showPassword ? 'text' : 'password'}
placeholder="Password"
value={proxyPartialPassword}
onChange={handleProxyPasswordInputChange}
suffixIcon={
<div className="flex items-center gap-1">
{proxyPassword && (
<button
type="button"
data-testid="clear-password-button"
onClick={() => {
setProxyPassword('')
}}
className="p-1 hover:text-[hsla(var(--text-primary))]"
>
<XIcon size={14} />
</button>
)}
<button
data-testid="password-visibility-toggle"
className="p-1 hover:text-[hsla(var(--text-primary))]"
type="button"
aria-label="Toggle password visibility"
onClick={() => setShowPassword(!showPassword)}
>
{showPassword ? (
<EyeOffIcon size={14} />
) : (
<EyeIcon size={14} />
)}
</button>
</div>
}
/>
</div>
</div>
</div>
</div>
{/* No Proxy */}
<div className="space-y-4">
<div className="space-y-2">
<div className="flex items-start justify-between">
<div className="space-y-1">
<label className="text-sm font-medium">No Proxy</label>
<p className="text-xs text-[hsla(var(--text-secondary))]">
List of hosts that should bypass the proxy.
</p>
</div>
<div className="w-1/2">
<Input
data-testid="no-proxy-input"
placeholder="localhost, 127.0.0.1"
value={noProxy}
onChange={onNoProxyChange}
suffixIcon={
<div className="flex items-center gap-1">
{noProxy && (
<button
type="button"
onClick={() => setNoProxy('')}
className="p-1 hover:text-[hsla(var(--text-primary))]"
>
<XIcon size={14} />
</button>
)}
</div>
}
/>
</div>
</div>
</div>
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold">SSL Verification</h2>
</div>
{/* Ignore SSL certificates */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="max-w-[66%] flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">
Ignore SSL certificates
</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Allow self-signed or unverified certificates (may be required
for certain proxies). Enable this reduces security. Only use
this if you trust your proxy server.
</p>
</div>
<Switch
data-testid="ignore-ssl-switch"
checked={ignoreSSL}
onChange={(e) => {
setIgnoreSSL(e.target.checked)
updatePullOptions()
}}
/>
</div>
{/* Verify Proxy SSL */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Verify Proxy SSL</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Validate SSL certificate when connecting to the proxy server.
</p>
</div>
<Switch
data-testid="verify-proxy-ssl-switch"
checked={verifyProxySSL}
onChange={(e) => {
setVerifyProxySSL(e.target.checked)
updatePullOptions()
}}
/>
</div>
{/* Verify Proxy Host SSL */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">
Verify Proxy Host SSL
</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Validate SSL certificate of the proxy server host.
</p>
</div>
<Switch
data-testid="verify-proxy-host-ssl-switch"
checked={verifyProxyHostSSL}
onChange={(e) => {
setVerifyProxyHostSSL(e.target.checked)
updatePullOptions()
}}
/>
</div>
{/* Verify Peer SSL */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Verify Peer SSL</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Validate SSL certificate of the peer connections.
</p>
</div>
<Switch
data-testid="verify-peer-ssl-switch"
checked={verifyPeerSSL}
onChange={(e) => {
setVerifyPeerSSL(e.target.checked)
updatePullOptions()
}}
/>
</div>
{/* Verify Host SSL */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">Verify Host SSL</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Validate SSL certificate of destination hosts.
</p>
</div>
<Switch
data-testid="verify-host-ssl-switch"
checked={verifyHostSSL}
onChange={(e) => {
setVerifyHostSSL(e.target.checked)
updatePullOptions()
}}
/>
</div>
</div>
</div>
</ScrollArea>
)
}
export default ProxySettings

View File

@ -64,7 +64,6 @@ describe('Advanced', () => {
await waitFor(() => {
expect(screen.getByText('Experimental Mode')).toBeInTheDocument()
expect(screen.getByText('HTTPS Proxy')).toBeInTheDocument()
expect(screen.getByText('Ignore SSL certificates')).toBeInTheDocument()
expect(screen.getByText('Jan Data Folder')).toBeInTheDocument()
expect(screen.getByText('Reset to Factory Settings')).toBeInTheDocument()
})
@ -102,28 +101,6 @@ describe('Advanced', () => {
expect(proxyToggle).toBeChecked()
})
it('updates proxy settings', async () => {
render(<Advanced />)
let proxyInput
await waitFor(() => {
const proxyToggle = screen.getByTestId(/proxy-switch/i)
fireEvent.click(proxyToggle)
proxyInput = screen.getByTestId(/proxy-input/i)
fireEvent.change(proxyInput, { target: { value: 'http://proxy.com' } })
})
expect(proxyInput).toHaveValue('http://proxy.com')
})
it('toggles ignore SSL certificates', async () => {
render(<Advanced />)
let ignoreSslToggle
await waitFor(() => {
expect(screen.getByText('Ignore SSL certificates')).toBeInTheDocument()
ignoreSslToggle = screen.getByTestId(/ignore-ssl-switch/i)
fireEvent.click(ignoreSslToggle)
})
expect(ignoreSslToggle).toBeChecked()
})
it('renders DataFolder component', async () => {
render(<Advanced />)

View File

@ -1,6 +1,6 @@
'use client'
import { useEffect, useState, useCallback, ChangeEvent } from 'react'
import { useEffect, useState, ChangeEvent } from 'react'
import { openExternalUrl, AppConfiguration } from '@janhq/core'
@ -15,7 +15,7 @@ import {
} from '@janhq/joi'
import { useAtom, useAtomValue, useSetAtom } from 'jotai'
import { ChevronDownIcon } from 'lucide-react'
import { ChevronDownIcon, ArrowRightIcon } from 'lucide-react'
import { AlertTriangleIcon, AlertCircleIcon } from 'lucide-react'
import { twMerge } from 'tailwind-merge'
@ -35,8 +35,6 @@ import FactoryReset from './FactoryReset'
import {
experimentalFeatureEnabledAtom,
ignoreSslAtom,
proxyAtom,
proxyEnabledAtom,
vulkanEnabledAtom,
quickAskEnabledAtom,
@ -56,7 +54,7 @@ type GPU = {
* Advanced Settings Screen
* @returns
*/
const Advanced = () => {
const Advanced = ({ setSubdir }: { setSubdir: (subdir: string) => void }) => {
const [experimentalEnabled, setExperimentalEnabled] = useAtom(
experimentalFeatureEnabledAtom
)
@ -64,10 +62,6 @@ const Advanced = () => {
const [proxyEnabled, setProxyEnabled] = useAtom(proxyEnabledAtom)
const quickAskEnabled = useAtomValue(quickAskEnabledAtom)
const [proxy, setProxy] = useAtom(proxyAtom)
const [ignoreSSL, setIgnoreSSL] = useAtom(ignoreSslAtom)
const [partialProxy, setPartialProxy] = useState<string>(proxy)
const [gpuEnabled, setGpuEnabled] = useState<boolean>(false)
const [gpuList, setGpuList] = useState<GPU[]>([])
const [gpusInUse, setGpusInUse] = useState<string[]>([])
@ -98,22 +92,6 @@ const Advanced = () => {
() => configurePullOptions(),
300
)
/**
* Handle proxy change
*/
const onProxyChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
const value = event.target.value || ''
setPartialProxy(value)
if (value.trim().startsWith('http')) {
setProxy(value.trim())
} else {
setProxy('')
}
updatePullOptions()
},
[setPartialProxy, setProxy, updatePullOptions]
)
/**
* Update Quick Ask Enabled
@ -448,61 +426,32 @@ const Advanced = () => {
<DataFolder />
{/* Proxy */}
{/* Proxy Settings Link */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="w-full space-y-1">
<div className="flex w-full justify-between gap-x-2">
<h6 className="font-semibold capitalize">HTTPS Proxy</h6>
<div className="flex w-full cursor-pointer items-center justify-between">
<div className="space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">HTTPS Proxy</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Optional proxy server for internet connections
</p>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Optional proxy server for internet connections. Only HTTPS proxies
supported.
</p>
</div>
<div className="flex w-full flex-shrink-0 flex-col items-end gap-2 pr-1 sm:w-1/2">
<Switch
data-testid="proxy-switch"
checked={proxyEnabled}
onChange={() => {
setProxyEnabled(!proxyEnabled)
updatePullOptions()
}}
/>
<div className="w-full">
<Input
data-testid="proxy-input"
placeholder={'http://<user>:<password>@<domain or IP>:<port>'}
value={partialProxy}
onChange={onProxyChange}
<div className="flex items-center gap-2">
<Switch
data-testid="proxy-switch"
checked={proxyEnabled}
onClick={(e) => {
e.stopPropagation()
setProxyEnabled(!proxyEnabled)
updatePullOptions()
}}
/>
<ArrowRightIcon size={16} onClick={() => setSubdir('proxy')} />
</div>
</div>
</div>
{/* Ignore SSL certificates */}
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">
<div className="flex gap-x-2">
<h6 className="font-semibold capitalize">
Ignore SSL certificates
</h6>
</div>
<p className="font-medium leading-relaxed text-[hsla(var(--text-secondary))]">
Allow self-signed or unverified certificates - may be required for
certain proxies.
</p>
</div>
<Switch
data-testid="ignore-ssl-switch"
checked={ignoreSSL}
onChange={(e) => {
setIgnoreSSL(e.target.checked)
updatePullOptions()
}}
/>
</div>
{experimentalEnabled && (
<div className="flex w-full flex-col items-start justify-between gap-4 border-b border-[hsla(var(--app-border))] py-4 first:pt-0 last:border-none sm:flex-row">
<div className="flex-shrink-0 space-y-1">

View File

@ -1,9 +1,12 @@
import React, { useState } from 'react'
import { InferenceEngine } from '@janhq/core'
import { useAtomValue } from 'jotai'
import { useGetEngines } from '@/hooks/useEngineManagement'
import Advanced from '@/screens/Settings/Advanced'
import ProxySettings from '@/screens/Settings/Advanced/ProxySettings'
import AppearanceOptions from '@/screens/Settings/Appearance'
import ExtensionCatalog from '@/screens/Settings/CoreExtensions'
import Engines from '@/screens/Settings/Engines'
@ -21,6 +24,7 @@ import { selectedSettingAtom } from '@/helpers/atoms/Setting.atom'
const SettingDetail = () => {
const selectedSetting = useAtomValue(selectedSettingAtom)
const { engines } = useGetEngines()
const [subdir, setSubdir] = useState<string | null>(null)
switch (selectedSetting) {
case 'Engines':
@ -39,7 +43,12 @@ const SettingDetail = () => {
return <Privacy />
case 'Advanced Settings':
return <Advanced />
switch (subdir) {
case 'proxy':
return <ProxySettings onBack={() => setSubdir(null)} />
default:
return <Advanced setSubdir={setSubdir} />
}
case 'My Models':
return <MyModels />