1- import { useEffect , useState } from 'react' ;
1+ import { useEffect , useRef , useState } from 'react' ;
22
33import { useT } from '../../../lib/i18n/I18nContext' ;
44import { useCoreState } from '../../../providers/CoreStateProvider' ;
88 openhumanUpdateSearchSettings ,
99 type SearchEngineId ,
1010 type SearchSettings ,
11+ type SearchSettingsUpdate ,
1112} from '../../../utils/tauriCommands/config' ;
1213import SettingsHeader from '../components/SettingsHeader' ;
1314import { useSettingsNavigation } from '../hooks/useSettingsNavigation' ;
@@ -19,13 +20,38 @@ type Status =
1920 | { kind : 'saved' }
2021 | { kind : 'error' ; message : string } ;
2122
23+ /**
24+ * Tri-state web-access mode for the unified fetch + browser allowlist.
25+ * - `all` → `allow_all: true` (the `"*"` wildcard)
26+ * - `custom` → `allow_all: false` + an explicit host list (textarea)
27+ * - `block` → `allow_all: false` + an empty host list (no web access)
28+ *
29+ * `block` and an empty `custom` are indistinguishable once persisted (both are
30+ * `allow_all: false` + `[]`); the distinction only matters locally while
31+ * editing.
32+ */
33+ type AccessMode = 'all' | 'custom' | 'block' ;
34+
2235interface EngineOption {
2336 id : SearchEngineId ;
2437 label : string ;
2538 description : string ;
2639 requiresKey : boolean ;
2740}
2841
42+ /**
43+ * Normalize a user-entered allowed-site entry down to a bare host so it
44+ * matches `url_guard`'s host-based comparison. Strips a leading scheme and any
45+ * path/query/fragment — e.g. `https://reuters.com/markets` → `reuters.com` —
46+ * and trims surrounding whitespace. The `*` allow-all wildcard is preserved.
47+ */
48+ const normalizeAllowedHost = ( raw : string ) : string =>
49+ raw
50+ . trim ( )
51+ . replace ( / ^ [ a - z ] [ a - z 0 - 9 + . - ] * : \/ \/ / i, '' )
52+ . replace ( / \/ .* $ / , '' )
53+ . trim ( ) ;
54+
2955const SearchPanel = ( { embedded = false } : { embedded ?: boolean } ) => {
3056 const { t } = useT ( ) ;
3157 const { navigateBack, breadcrumbs } = useSettingsNavigation ( ) ;
@@ -38,6 +64,15 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
3864 const [ braveKey , setBraveKey ] = useState < string > ( '' ) ;
3965 const [ showParallel , setShowParallel ] = useState ( false ) ;
4066 const [ showBrave , setShowBrave ] = useState ( false ) ;
67+ // Editor text for the allowed-websites host list (one host per line). The
68+ // "*" wildcard is represented by the access mode, not shown here.
69+ const [ allowedText , setAllowedText ] = useState < string > ( '' ) ;
70+ // Tri-state web-access mode for the unified fetch + browser allowlist.
71+ const [ mode , setMode ] = useState < AccessMode > ( 'all' ) ;
72+ // Sync editor + mode from settings exactly once, so a later settings refresh
73+ // (e.g. after saving an engine change) can't clobber the user's in-progress
74+ // host edits or chosen mode.
75+ const initializedRef = useRef ( false ) ;
4176
4277 const ENGINES : EngineOption [ ] = [
4378 {
@@ -80,6 +115,15 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
80115 } ;
81116 } , [ ] ) ;
82117
118+ // Reflect the loaded allowlist into the editor + mode, exactly once.
119+ useEffect ( ( ) => {
120+ if ( ! settings || initializedRef . current ) return ;
121+ initializedRef . current = true ;
122+ const explicit = settings . allowed_domains . filter ( d => d !== '*' ) ;
123+ setAllowedText ( explicit . join ( '\n' ) ) ;
124+ setMode ( settings . allow_all ? 'all' : explicit . length > 0 ? 'custom' : 'block' ) ;
125+ } , [ settings ] ) ;
126+
83127 const selectedEngine = ( settings ?. engine as SearchEngineId | undefined ) ?? 'managed' ;
84128
85129 const persistEngine = async ( next : SearchEngineId ) => {
@@ -115,6 +159,38 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
115159 }
116160 } ;
117161
162+ const persistSearchUpdate = async ( update : SearchSettingsUpdate ) => {
163+ if ( ! settings || status . kind === 'saving' ) return ;
164+ setStatus ( { kind : 'saving' } ) ;
165+ try {
166+ await openhumanUpdateSearchSettings ( update ) ;
167+ const refreshed = await openhumanGetSearchSettings ( ) ;
168+ setSettings ( refreshed . result ) ;
169+ setStatus ( { kind : 'saved' } ) ;
170+ } catch ( err ) {
171+ setStatus ( { kind : 'error' , message : err instanceof Error ? err . message : String ( err ) } ) ;
172+ }
173+ } ;
174+
175+ // Switch web-access mode. "Allow all" / "Block all" persist immediately;
176+ // "Custom" only reveals the host editor (its Save button persists the list),
177+ // and we keep whatever the user has already typed.
178+ const selectMode = ( next : AccessMode ) => {
179+ if ( status . kind === 'saving' ) return ;
180+ setMode ( next ) ;
181+ if ( next === 'all' ) {
182+ void persistSearchUpdate ( { allow_all : true } ) ;
183+ } else if ( next === 'block' ) {
184+ void persistSearchUpdate ( { allowed_domains : [ ] , allow_all : false } ) ;
185+ }
186+ } ;
187+
188+ const persistAllowedDomains = ( ) => {
189+ const domains = allowedText . split ( '\n' ) . map ( normalizeAllowedHost ) . filter ( Boolean ) ;
190+ // Editing the explicit host list implies "not allow-all".
191+ void persistSearchUpdate ( { allowed_domains : domains , allow_all : false } ) ;
192+ } ;
193+
118194 const isConfigured = ( engine : SearchEngineId ) : boolean => {
119195 if ( ! settings ) return false ;
120196 if ( engine === 'managed' ) return true ;
@@ -260,6 +336,75 @@ const SearchPanel = ({ embedded = false }: { embedded?: boolean }) => {
260336 />
261337 </ div >
262338
339+ { /* Allowed websites — unified host allowlist shared by web_fetch /
340+ curl and (when enabled) the browser tool. Web search is not
341+ gated by this list. */ }
342+ < div className = "rounded-xl border border-stone-200 dark:border-neutral-800 bg-white dark:bg-neutral-900 p-3 space-y-2" >
343+ { /* Section heading, not a form label — use a <p> so screen
344+ readers don't announce an orphan <label> with no htmlFor. */ }
345+ < p className = "text-xs font-semibold text-stone-700 dark:text-neutral-200" >
346+ { t ( 'settings.search.allowedSitesLabel' ) }
347+ </ p >
348+ < div
349+ role = "radiogroup"
350+ aria-label = { t ( 'settings.search.accessModeAria' ) }
351+ className = "flex rounded-lg border border-stone-200 dark:border-neutral-800 overflow-hidden" >
352+ { (
353+ [
354+ [ 'all' , 'settings.search.accessAllowAll' ] ,
355+ [ 'custom' , 'settings.search.accessCustom' ] ,
356+ [ 'block' , 'settings.search.accessBlockAll' ] ,
357+ ] as const
358+ ) . map ( ( [ value , labelKey ] , idx ) => {
359+ const selected = mode === value ;
360+ return (
361+ < button
362+ key = { value }
363+ type = "button"
364+ role = "radio"
365+ aria-checked = { selected }
366+ onClick = { ( ) => selectMode ( value ) }
367+ disabled = { status . kind === 'saving' }
368+ className = { `flex-1 px-3 py-1.5 text-xs font-medium transition-colors disabled:opacity-50 focus:outline-none focus-visible:bg-primary-50 dark:focus-visible:bg-primary-900/30 ${
369+ idx !== 0 ? 'border-l border-stone-200 dark:border-neutral-800' : ''
370+ } ${
371+ selected
372+ ? 'bg-primary-500 text-white'
373+ : 'bg-white dark:bg-neutral-900 text-stone-700 dark:text-neutral-200 hover:bg-stone-50 dark:hover:bg-neutral-800/60'
374+ } `} >
375+ { t ( labelKey ) }
376+ </ button >
377+ ) ;
378+ } ) }
379+ </ div >
380+ < p className = "text-[11px] text-stone-500 dark:text-neutral-400 leading-relaxed" >
381+ { mode === 'all'
382+ ? t ( 'settings.search.allowedSitesAllOn' )
383+ : mode === 'block'
384+ ? t ( 'settings.search.accessBlockAllHint' )
385+ : t ( 'settings.search.allowedSitesHint' ) }
386+ </ p >
387+ { mode === 'custom' && (
388+ < >
389+ < textarea
390+ value = { allowedText }
391+ onChange = { e => setAllowedText ( e . target . value ) }
392+ rows = { 4 }
393+ spellCheck = { false }
394+ placeholder = { t ( 'settings.search.allowedSitesPlaceholder' ) }
395+ className = "w-full rounded-lg border border-stone-200 dark:border-neutral-800 bg-stone-50 dark:bg-neutral-800/60 px-2 py-1.5 text-xs font-mono text-stone-800 dark:text-neutral-100 focus:outline-none focus-visible:border-primary-400"
396+ />
397+ < button
398+ type = "button"
399+ onClick = { ( ) => persistAllowedDomains ( ) }
400+ disabled = { status . kind === 'saving' }
401+ className = "px-3 py-1.5 rounded-lg text-xs font-medium bg-primary-500 text-white hover:bg-primary-600 disabled:opacity-50" >
402+ { t ( 'settings.search.allowedSitesSave' ) }
403+ </ button >
404+ </ >
405+ ) }
406+ </ div >
407+
263408 < div
264409 role = "status"
265410 aria-live = "polite"
0 commit comments