Skip to content

Commit f7defe1

Browse files
committed
feat: GitHub-style secrets editor with .env import/converter tool
1 parent 6aa5ac0 commit f7defe1

4 files changed

Lines changed: 380 additions & 22 deletions

File tree

‎internal/app/handler.go‎

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,6 @@ func (h *WebHandler) AppEditForm(w http.ResponseWriter, r *http.Request, render
503503
return
504504
}
505505

506-
saveFormSecrets(r, h.service, id)
507506
saveFormFiles(r, h.service, id)
508507

509508
go h.service.Redeploy(id)

‎internal/http/templates/apps_add.html‎

Lines changed: 270 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -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&#10;KEY2=value2&#10;..." 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*)environment\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*environment\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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
375+
}
376+
function escapeAttr(s) {
377+
return s.replace(/&/g,'&amp;').replace(/'/g,"\\'").replace(/"/g,'&quot;');
124378
}
125379
function addFileRow() {
126380
var div = document.createElement('div');

0 commit comments

Comments
 (0)