@@ -22,6 +22,9 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
2222 const [ savedToken , setSavedToken ] = createSignal ( '' ) ;
2323 const [ copied , setCopied ] = createSignal < 'password' | 'token' | null > ( null ) ;
2424 const [ themeMode , setThemeMode ] = createSignal < 'system' | 'light' | 'dark' > ( 'system' ) ;
25+ const [ bootstrapToken , setBootstrapToken ] = createSignal ( '' ) ;
26+ const [ isUnlocking , setIsUnlocking ] = createSignal ( false ) ;
27+ const [ isUnlocked , setIsUnlocked ] = createSignal ( false ) ;
2528
2629 const applyTheme = ( mode : 'system' | 'light' | 'dark' ) => {
2730 if ( mode === 'light' ) {
@@ -77,6 +80,15 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
7780 return Array . from ( array , ( byte ) => byte . toString ( 16 ) . padStart ( 2 , '0' ) ) . join ( '' ) ;
7881 } ;
7982
83+ const handleUnlock = ( ) => {
84+ if ( ! bootstrapToken ( ) . trim ( ) ) {
85+ showError ( 'Please enter the bootstrap token' ) ;
86+ return ;
87+ }
88+ // Simple client-side unlock - actual validation happens during setup
89+ setIsUnlocked ( true ) ;
90+ } ;
91+
8092 const handleSetup = async ( ) => {
8193 // Validate custom password if used
8294 if ( useCustomPassword ( ) ) {
@@ -104,15 +116,23 @@ export const FirstRunSetup: Component<{ force?: boolean; showLegacyBanner?: bool
104116 setApiClientToken ( token ) ;
105117
106118 try {
119+ const headers : Record < string , string > = { 'Content-Type' : 'application/json' } ;
120+
121+ // Include bootstrap token if we're in first-run setup (not force mode)
122+ if ( ! props . force && bootstrapToken ( ) ) {
123+ headers [ 'X-Setup-Token' ] = bootstrapToken ( ) . trim ( ) ;
124+ }
125+
107126 const response = await fetch ( '/api/security/quick-setup' , {
108127 method : 'POST' ,
109- headers : { 'Content-Type' : 'application/json' } ,
128+ headers,
110129 credentials : 'include' , // Include cookies for CSRF
111130 body : JSON . stringify ( {
112131 username : username ( ) ,
113132 password : finalPassword ,
114133 apiToken : token ,
115134 force : props . force ?? false ,
135+ setupToken : bootstrapToken ( ) . trim ( ) , // Also include in body as fallback
116136 } ) ,
117137 } ) ;
118138
@@ -251,7 +271,78 @@ IMPORTANT: Keep these credentials secure!
251271 </ div >
252272
253273 < div class = "bg-white dark:bg-gray-800 rounded-xl shadow-2xl overflow-hidden" >
254- < Show when = { ! showCredentials ( ) } >
274+ { /* Bootstrap Token Unlock Screen */ }
275+ < Show when = { ! isUnlocked ( ) && ! showCredentials ( ) && ! props . force } >
276+ < div class = "p-8" >
277+ < SectionHeader
278+ title = "Unlock Setup Wizard"
279+ size = "lg"
280+ class = "mb-6"
281+ titleClass = "text-gray-800 dark:text-gray-100"
282+ />
283+
284+ < div class = "space-y-6" >
285+ { /* Instructions */ }
286+ < div class = "bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4" >
287+ < p class = "text-sm text-blue-900 dark:text-blue-100 font-medium mb-2" >
288+ To begin setup, retrieve the bootstrap token from your Pulse host:
289+ </ p >
290+ < div class = "space-y-2" >
291+ < div class = "bg-white dark:bg-gray-800 rounded p-3 font-mono text-xs text-gray-800 dark:text-gray-200" >
292+ < div class = "text-blue-600 dark:text-blue-400 mb-1" > # Standard installation:</ div >
293+ cat /etc/pulse/.bootstrap_token
294+ </ div >
295+ < div class = "bg-white dark:bg-gray-800 rounded p-3 font-mono text-xs text-gray-800 dark:text-gray-200" >
296+ < div class = "text-blue-600 dark:text-blue-400 mb-1" > # Docker/Helm:</ div >
297+ cat /data/.bootstrap_token
298+ </ div >
299+ </ div >
300+ </ div >
301+
302+ { /* Token Input */ }
303+ < div >
304+ < label class = "block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2" >
305+ Bootstrap Token
306+ </ label >
307+ < input
308+ type = "text"
309+ value = { bootstrapToken ( ) }
310+ onInput = { ( e ) => setBootstrapToken ( e . currentTarget . value ) }
311+ onKeyPress = { ( e ) => e . key === 'Enter' && handleUnlock ( ) }
312+ class = "w-full px-4 py-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 focus:ring-2 focus:ring-blue-500 focus:border-transparent font-mono text-sm"
313+ placeholder = "Paste the token from your host"
314+ autofocus
315+ />
316+ < p class = "text-xs text-gray-500 dark:text-gray-400 mt-2" >
317+ This one-time token ensures only someone with host access can configure Pulse
318+ </ p >
319+ </ div >
320+
321+ { /* Security Note */ }
322+ < div class = "bg-gray-50 dark:bg-gray-900 rounded-lg p-4" >
323+ < p class = "text-sm text-gray-600 dark:text-gray-400" >
324+ < span class = "font-semibold text-gray-800 dark:text-gray-200" > Why this step?</ span >
325+ < br />
326+ The bootstrap token prevents unauthorized access to your unconfigured Pulse instance.
327+ It's automatically removed after you complete the setup wizard.
328+ </ p >
329+ </ div >
330+
331+ { /* Unlock Button */ }
332+ < button
333+ type = "button"
334+ onClick = { handleUnlock }
335+ disabled = { isUnlocking ( ) || ! bootstrapToken ( ) . trim ( ) }
336+ class = "w-full py-3 px-4 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400 text-white rounded-lg font-medium transition-colors disabled:cursor-not-allowed"
337+ >
338+ { isUnlocking ( ) ? 'Unlocking...' : 'Unlock Wizard' }
339+ </ button >
340+ </ div >
341+ </ div >
342+ </ Show >
343+
344+ { /* Setup Form - only shown after unlock or in force mode */ }
345+ < Show when = { ( isUnlocked ( ) || props . force ) && ! showCredentials ( ) } >
255346 < div class = "p-8" >
256347 < SectionHeader
257348 title = "Initial security setup"
0 commit comments