@@ -80,16 +80,37 @@ <h1>{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}</h1>
8080
8181 < fieldset >
8282 < legend > Secrets (optional — use < code > ${KEY}</ code > in compose to reference)</ legend >
83- {{if .Secrets}}
84- {{range .Secrets}}
85- < div class ="grid " style ="margin-bottom:0.4em ">
86- < input type ="text " name ="secret_key " value ="{{.Key}} " placeholder ="KEY " autocomplete ="off ">
87- < input type ="text " name ="secret_val " value ="{{.Value}} " placeholder ="value " autocomplete ="off ">
83+ < div id ="secrets-list ">
84+ {{if .IsEdit}}
85+ {{if .Secrets}}
86+ {{range .Secrets}}
87+ < div class ="secret-row " data-key ="{{.Key}} ">
88+ < code > {{.Key}}</ code >
89+ < span class ="secret-val "> ••••••••</ span >
90+ < button type ="button " class ="btn btn-ghost " style ="font-size:0.78em;padding:0.15em 0.5em " onclick ="editSecret(this, '{{.Key}}') "> Update</ button >
91+ < button type ="button " class ="btn btn-ghost btn-red " style ="font-size:0.78em;padding:0.15em 0.5em " onclick ="removeApiSecret(this, '{{.Key}}') "> ✕</ button >
92+ </ div >
93+ {{end}}
94+ {{else}}
95+ < p id ="no-secrets-msg " style ="color:var(--text-muted);font-size:0.85em "> No secrets configured.</ p >
96+ {{end}}
97+ {{else}}
98+ < p id ="no-secrets-msg " style ="color:var(--text-muted);font-size:0.85em "> No secrets added yet.</ p >
99+ {{end}}
88100 </ div >
89- {{end}}
90- {{end}}
91- < div id ="secret-rows "> </ div >
92- < button type ="button " class ="btn btn-ghost " onclick ="addSecretRow() " style ="margin-top:0.3em "> + Add Secret</ button >
101+ < div style ="margin-top:0.8em;display:flex;gap:0.5em;flex-wrap:wrap;align-items:flex-end ">
102+ < input type ="text " id ="secret-key " placeholder ="KEY " autocomplete ="off " style ="width:25%;min-width:140px ">
103+ < input type ="text " id ="secret-value " placeholder ="value " autocomplete ="off " style ="width:35%;min-width:180px ">
104+ < button type ="button " class ="btn btn-primary " onclick ="addFormSecret() " style ="white-space:nowrap "> Add Secret</ button >
105+ </ div >
106+ < div id ="secret-hidden-fields "> </ div >
107+ < details style ="margin-top:0.6em ">
108+ < summary style ="cursor:pointer;font-size:0.82em;color:var(--text-muted) "> Import from .env</ summary >
109+ < div style ="margin-top:0.4em ">
110+ < textarea id ="env-import " rows ="4 " placeholder ="KEY1=value1 KEY2=value2 ... " style ="font-size:0.82em "> </ textarea >
111+ < button type ="button " class ="btn btn-ghost " onclick ="importEnv() " style ="margin-top:0.35em;font-size:0.8em "> Import</ button >
112+ </ div >
113+ </ details >
93114 </ fieldset >
94115
95116 < fieldset >
@@ -113,14 +134,247 @@ <h1>{{if .IsEdit}}Edit App{{else}}Deploy App{{end}}</h1>
113134 </ form >
114135</ div >
115136< script >
116- function addSecretRow ( ) {
137+ { { if . IsEdit } }
138+ var appId = { { . App . ID } } ;
139+ function addFormSecret ( ) {
140+ var key = document . getElementById ( 'secret-key' ) . value . trim ( ) ;
141+ var val = document . getElementById ( 'secret-value' ) . value ;
142+ if ( ! key ) { alert ( 'Key is required' ) ; return ; }
143+ fetch ( 'api/apps/' + appId + '/secrets' , {
144+ method : 'POST' ,
145+ headers : { 'Content-Type' : 'application/json' } ,
146+ body : JSON . stringify ( { key : key , value : val } )
147+ } ) . then ( r => r . json ( ) ) . then ( d => {
148+ if ( d . error ) { alert ( d . error ) ; return ; }
149+ addSecretRowToDOM ( key ) ;
150+ document . getElementById ( 'secret-key' ) . value = '' ;
151+ document . getElementById ( 'secret-value' ) . value = '' ;
152+ } ) ;
153+ }
154+ function addSecretRowToDOM ( key ) {
155+ var el = document . getElementById ( 'no-secrets-msg' ) ;
156+ if ( el ) el . remove ( ) ;
117157 var div = document . createElement ( 'div' ) ;
118- div . className = 'grid' ;
119- div . style . marginBottom = '0.4em' ;
120- div . innerHTML = '<input type="text" name="secret_key" placeholder="KEY" autocomplete="off">'
121- + '<input type="text" name="secret_val" placeholder="value" autocomplete="off">'
122- + '<button type="button" class="btn btn-ghost btn-red" style="padding:0.2em 0.5em;font-size:0.8em" onclick="this.parentElement.remove()">x</button>' ;
123- document . getElementById ( 'secret-rows' ) . appendChild ( div ) ;
158+ div . className = 'secret-row' ;
159+ div . setAttribute ( 'data-key' , key ) ;
160+ div . innerHTML = '<code>' + escapeHtml ( key ) + '</code>'
161+ + '<span class="secret-val">••••••••</span>'
162+ + '<button type="button" class="btn btn-ghost" style="font-size:0.78em;padding:0.15em 0.5em" onclick="editSecret(this, \'' + escapeAttr ( key ) + '\')">Update</button>'
163+ + '<button type="button" class="btn btn-ghost btn-red" style="font-size:0.78em;padding:0.15em 0.5em" onclick="removeApiSecret(this, \'' + escapeAttr ( key ) + '\')">✕</button>' ;
164+ document . getElementById ( 'secrets-list' ) . appendChild ( div ) ;
165+ }
166+ function editSecret ( btn , key ) {
167+ var row = btn . parentElement ;
168+ var valSpan = row . querySelector ( '.secret-val' ) ;
169+ var updateBtn = btn ;
170+ var existingInput = row . querySelector ( 'input' ) ;
171+ if ( existingInput && updateBtn . textContent === 'Save' ) {
172+ var newVal = existingInput . value ;
173+ updateBtn . textContent = 'Update' ;
174+ var cancelBtn = row . querySelector ( 'button[onclick*="cancelEdit"]' ) ;
175+ if ( cancelBtn ) cancelBtn . remove ( ) ;
176+ existingInput . replaceWith ( valSpan ) ;
177+ updateBtn . disabled = false ;
178+ if ( newVal ) {
179+ updateBtn . disabled = true ;
180+ fetch ( 'api/apps/' + appId + '/secrets' , {
181+ method : 'POST' ,
182+ headers : { 'Content-Type' : 'application/json' } ,
183+ body : JSON . stringify ( { key : key , value : newVal } )
184+ } ) . then ( r => r . json ( ) ) . then ( d => {
185+ updateBtn . disabled = false ;
186+ } ) ;
187+ }
188+ return ;
189+ }
190+ valSpan . style . display = 'none' ;
191+ var input = document . createElement ( 'input' ) ;
192+ input . type = 'text' ;
193+ input . placeholder = 'new value' ;
194+ input . style . cssText = 'width:auto;min-width:150px;padding:0.35em 0.55em;font-size:0.85em' ;
195+ valSpan . parentElement . insertBefore ( input , btn ) ;
196+ updateBtn . textContent = 'Save' ;
197+ var cancel = document . createElement ( 'button' ) ;
198+ cancel . type = 'button' ;
199+ cancel . className = 'btn btn-ghost' ;
200+ cancel . style . cssText = 'font-size:0.78em;padding:0.15em 0.5em' ;
201+ cancel . textContent = 'Cancel' ;
202+ cancel . onclick = function ( ) { editSecret ( btn , key ) ; } ;
203+ btn . parentElement . insertBefore ( cancel , btn . nextSibling ) ;
204+ input . focus ( ) ;
205+ }
206+ function removeApiSecret ( btn , key ) {
207+ if ( ! confirm ( 'Delete secret "' + key + '"?' ) ) return ;
208+ fetch ( 'api/apps/' + appId + '/secrets/' + encodeURIComponent ( key ) , { method : 'DELETE' } )
209+ . then ( r => r . json ( ) ) . then ( d => {
210+ var row = btn . parentElement ;
211+ row . remove ( ) ;
212+ } ) ;
213+ }
214+ function importEnv ( ) {
215+ var text = document . getElementById ( 'env-import' ) . value . trim ( ) ;
216+ if ( ! text ) { alert ( 'Paste KEY=VALUE pairs first' ) ; return ; }
217+ var lines = text . split ( / [ \r \n ] + / ) . filter ( function ( l ) { return l . trim ( ) && l . indexOf ( '=' ) > 0 ; } ) ;
218+ if ( lines . length === 0 ) { alert ( 'No valid KEY=VALUE lines found' ) ; return ; }
219+ var keys = [ ] ;
220+ lines . forEach ( function ( line ) {
221+ var idx = line . indexOf ( '=' ) ;
222+ var key = line . substring ( 0 , idx ) . trim ( ) ;
223+ var val = line . substring ( idx + 1 ) . trim ( ) ;
224+ if ( ! key || ! val ) return ;
225+ keys . push ( key ) ;
226+ fetch ( 'api/apps/' + appId + '/secrets' , {
227+ method : 'POST' ,
228+ headers : { 'Content-Type' : 'application/json' } ,
229+ body : JSON . stringify ( { key : key , value : val } )
230+ } ) . then ( r => r . json ( ) ) . then ( d => {
231+ if ( ! d . error ) addSecretRowToDOM ( key ) ;
232+ } ) ;
233+ } ) ;
234+ if ( keys . length > 0 ) injectEnvToCompose ( keys ) ;
235+ document . getElementById ( 'env-import' ) . value = '' ;
236+ }
237+ { { else } }
238+ var pendingSecrets = [ ] ;
239+ function addFormSecret ( ) {
240+ var key = document . getElementById ( 'secret-key' ) . value . trim ( ) ;
241+ var val = document . getElementById ( 'secret-value' ) . value ;
242+ if ( ! key ) { alert ( 'Key is required' ) ; return ; }
243+ if ( pendingSecrets . some ( function ( s ) { return s . key === key ; } ) ) { alert ( 'Key already added' ) ; return ; }
244+ pendingSecrets . push ( { key : key , value : val } ) ;
245+ renderPendingSecrets ( ) ;
246+ document . getElementById ( 'secret-key' ) . value = '' ;
247+ document . getElementById ( 'secret-value' ) . value = '' ;
248+ }
249+ function renderPendingSecrets ( ) {
250+ var list = document . getElementById ( 'secrets-list' ) ;
251+ var msg = document . getElementById ( 'no-secrets-msg' ) ;
252+ if ( msg ) msg . style . display = pendingSecrets . length ? 'none' : '' ;
253+ var existing = list . querySelectorAll ( '.secret-row' ) ;
254+ existing . forEach ( function ( r ) { r . remove ( ) ; } ) ;
255+ pendingSecrets . forEach ( function ( s ) {
256+ var div = document . createElement ( 'div' ) ;
257+ div . className = 'secret-row' ;
258+ div . setAttribute ( 'data-key' , s . key ) ;
259+ div . innerHTML = '<code>' + escapeHtml ( s . key ) + '</code>'
260+ + '<span class="secret-val">••••••••</span>'
261+ + '<button type="button" class="btn btn-ghost btn-red" style="font-size:0.78em;padding:0.15em 0.5em" onclick="removeFormSecret(\'' + escapeAttr ( s . key ) + '\')">✕</button>' ;
262+ list . appendChild ( div ) ;
263+ } ) ;
264+ syncHiddenFields ( ) ;
265+ }
266+ function removeFormSecret ( key ) {
267+ pendingSecrets = pendingSecrets . filter ( function ( s ) { return s . key !== key ; } ) ;
268+ renderPendingSecrets ( ) ;
269+ }
270+ function syncHiddenFields ( ) {
271+ var container = document . getElementById ( 'secret-hidden-fields' ) ;
272+ container . innerHTML = '' ;
273+ pendingSecrets . forEach ( function ( s ) {
274+ var k = document . createElement ( 'input' ) ;
275+ k . type = 'hidden' ;
276+ k . name = 'secret_key' ;
277+ k . value = s . key ;
278+ var v = document . createElement ( 'input' ) ;
279+ v . type = 'hidden' ;
280+ v . name = 'secret_val' ;
281+ v . value = s . value ;
282+ container . appendChild ( k ) ;
283+ container . appendChild ( v ) ;
284+ } ) ;
285+ }
286+ function importEnv ( ) {
287+ var text = document . getElementById ( 'env-import' ) . value . trim ( ) ;
288+ if ( ! text ) { alert ( 'Paste KEY=VALUE pairs first' ) ; return ; }
289+ var lines = text . split ( / [ \r \n ] + / ) . filter ( function ( l ) { return l . trim ( ) && l . indexOf ( '=' ) > 0 ; } ) ;
290+ if ( lines . length === 0 ) { alert ( 'No valid KEY=VALUE lines found' ) ; return ; }
291+ var keys = [ ] ;
292+ lines . forEach ( function ( line ) {
293+ var idx = line . indexOf ( '=' ) ;
294+ var key = line . substring ( 0 , idx ) . trim ( ) ;
295+ var val = line . substring ( idx + 1 ) . trim ( ) ;
296+ if ( ! key || ! val ) return ;
297+ keys . push ( key ) ;
298+ if ( ! pendingSecrets . some ( function ( s ) { return s . key === key ; } ) ) {
299+ pendingSecrets . push ( { key : key , value : val } ) ;
300+ }
301+ } ) ;
302+ renderPendingSecrets ( ) ;
303+ var envEl = document . getElementsByName ( 'env_vars' ) [ 0 ] ;
304+ if ( envEl ) {
305+ var existing = envEl . value ? envEl . value . split ( '\n' ) . filter ( function ( l ) { return l . trim ( ) } ) : [ ] ;
306+ var existingKeys = { } ;
307+ existing . forEach ( function ( l ) {
308+ var idx = l . indexOf ( '=' ) ;
309+ if ( idx > 0 ) existingKeys [ l . substring ( 0 , idx ) . trim ( ) ] = true ;
310+ } ) ;
311+ keys . forEach ( function ( k ) {
312+ if ( ! existingKeys [ k ] ) existing . push ( k + '=${' + k + '}' ) ;
313+ } ) ;
314+ envEl . value = existing . join ( '\n' ) + ( existing . length ? '\n' : '' ) ;
315+ }
316+ var composeEl = document . getElementsByName ( 'compose' ) [ 0 ] ;
317+ if ( composeEl && keys . length > 0 ) injectEnvToCompose ( keys ) ;
318+ document . getElementById ( 'env-import' ) . value = '' ;
319+ }
320+ { { end } }
321+ function injectEnvToCompose ( keys ) {
322+ var composeEl = document . getElementsByName ( 'compose' ) [ 0 ] ;
323+ if ( ! composeEl ) return ;
324+ var text = composeEl . value ;
325+ var lines = text . split ( '\n' ) ;
326+ var envIndex = - 1 , envIndent = '' , listMode = false ;
327+ var existing = { } ;
328+ for ( var i = 0 ; i < lines . length ; i ++ ) {
329+ var t = lines [ i ] ;
330+ var m = t . match ( / ^ ( \s * ) e n v i r o n m e n t \s * : \s * $ / ) ;
331+ if ( m ) {
332+ envIndex = i ;
333+ envIndent = m [ 1 ] ;
334+ listMode = t . trimEnd ( ) . endsWith ( '- ' ) ;
335+ for ( var j = i + 1 ; j < lines . length ; j ++ ) {
336+ var tj = lines [ j ] , tm ;
337+ if ( listMode ) tm = tj . match ( / ^ \s * - \s * ( \w + ) = / ) ;
338+ else tm = tj . match ( / ^ \s * ( \w + ) = / ) ;
339+ if ( tm ) existing [ tm [ 1 ] ] = true ;
340+ else if ( tj . trim ( ) !== '' && ! tm ) break ;
341+ }
342+ break ;
343+ }
344+ }
345+ var newLines = [ ] ;
346+ keys . forEach ( function ( k ) {
347+ if ( ! existing [ k ] ) {
348+ var indent = envIndent + ' ' ;
349+ newLines . push ( listMode ? indent + '- ' + k + '=${' + k + '}' : indent + k + '=${' + k + '}' ) ;
350+ }
351+ } ) ;
352+ if ( newLines . length === 0 ) return ;
353+ if ( envIndex === - 1 ) {
354+ var insIdx = - 1 ;
355+ for ( var i = lines . length - 1 ; i >= 0 ; i -- ) {
356+ if ( / ^ \s { 2 , 4 } \w / . test ( lines [ i ] ) && ! / ^ \s * e n v i r o n m e n t \s * : / . test ( lines [ i ] ) ) { insIdx = i ; break ; }
357+ }
358+ var section = [ ' environment:' ] ;
359+ newLines . forEach ( function ( l ) { section . push ( l ) ; } ) ;
360+ if ( insIdx >= 0 ) lines . splice ( insIdx + 1 , 0 , ...section ) ;
361+ else lines . push ( ...section ) ;
362+ } else {
363+ var after = envIndex ;
364+ for ( var i = envIndex + 1 ; i < lines . length ; i ++ ) {
365+ var t = lines [ i ] ;
366+ if ( t . trim ( ) === '' || / ^ \s * ( - \s * ) ? \w + = / . test ( t ) ) after = i ;
367+ else break ;
368+ }
369+ lines . splice ( after + 1 , 0 , ...newLines ) ;
370+ }
371+ composeEl . value = lines . join ( '\n' ) ;
372+ }
373+ function escapeHtml ( s ) {
374+ return s . replace ( / & / g, '&' ) . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) . replace ( / " / g, '"' ) ;
375+ }
376+ function escapeAttr ( s ) {
377+ return s . replace ( / & / g, '&' ) . replace ( / ' / g, "\\'" ) . replace ( / " / g, '"' ) ;
124378}
125379function addFileRow ( ) {
126380 var div = document . createElement ( 'div' ) ;
0 commit comments