Commitc5ab657unknown_key
feat(admin-integrations): copyable .env spec block
feat(admin-integrations): copyable .env spec block Solid-white spec card at the top of /admin/integrations that renders every integration key as a paste-ready KEY=value line, grouped by section, with masked placeholders for unset keys. One-click "Copy" button (with clipboard.writeText + execCommand fallback for non-secure contexts) writes the whole spec to the clipboard so operators can drop it straight into /etc/gluecron.env. Real secrets are never inlined — configured fields render as <unchanged — already set> so the spec is safe to share when bootstrapping a new host. Scoped styles under .admin-int-spec-* so the block can't bleed into the rest of the admin panel. Inline JS only — no new client deps.
1 file changed+198−0c5ab657b3d7af0f2036c435999a2a42fa3574196
1 changed file+198−0
Modifiedsrc/routes/admin-integrations.tsx+198−0View fileUnifiedSplit
@@ -288,6 +288,106 @@ const styles = `
288288 color: var(--text-strong);
289289 }
290290 .admin-int-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
291
292 /* Solid white .env spec block — high-contrast block the operator copies
293 and pastes into their /etc/gluecron.env file. Intentionally light so
294 it reads like a printed spec on the dark admin page. */
295 .admin-int-spec {
296 margin-bottom: var(--space-5);
297 background: #ffffff;
298 color: #0a0a0a;
299 border: 1px solid #e5e7eb;
300 border-radius: 14px;
301 overflow: hidden;
302 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
303 }
304 .admin-int-spec-head {
305 display: flex;
306 align-items: center;
307 justify-content: space-between;
308 gap: 12px;
309 padding: 12px 16px;
310 background: #f9fafb;
311 border-bottom: 1px solid #e5e7eb;
312 flex-wrap: wrap;
313 }
314 .admin-int-spec-title {
315 display: flex;
316 align-items: center;
317 gap: 10px;
318 font-family: var(--font-display, system-ui, sans-serif);
319 font-size: 14px;
320 font-weight: 700;
321 color: #111827;
322 letter-spacing: -0.005em;
323 margin: 0;
324 }
325 .admin-int-spec-title-dot {
326 width: 8px; height: 8px;
327 border-radius: 9999px;
328 background: linear-gradient(135deg, #8c6dff, #36c5d6);
329 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
330 }
331 .admin-int-spec-sub {
332 font-size: 12px;
333 color: #6b7280;
334 margin-left: 16px;
335 }
336 .admin-int-spec-copy {
337 display: inline-flex;
338 align-items: center;
339 gap: 6px;
340 padding: 6px 12px;
341 font-size: 12.5px;
342 font-weight: 600;
343 color: #111827;
344 background: #ffffff;
345 border: 1px solid #d1d5db;
346 border-radius: 8px;
347 cursor: pointer;
348 font-family: inherit;
349 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
350 }
351 .admin-int-spec-copy:hover {
352 background: #f3f4f6;
353 border-color: #9ca3af;
354 }
355 .admin-int-spec-copy.is-copied {
356 background: #ecfdf5;
357 border-color: #6ee7b7;
358 color: #047857;
359 }
360 .admin-int-spec-copy svg { display: block; }
361 .admin-int-spec-pre {
362 margin: 0;
363 padding: 18px 20px;
364 font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
365 font-size: 13px;
366 line-height: 1.7;
367 color: #0a0a0a;
368 background: #ffffff;
369 white-space: pre;
370 overflow-x: auto;
371 tab-size: 2;
372 }
373 .admin-int-spec-pre .c { color: #6b7280; }
374 .admin-int-spec-pre .k { color: #1f2937; font-weight: 600; }
375 .admin-int-spec-pre .v { color: #047857; }
376 .admin-int-spec-pre .vp { color: #9ca3af; }
377 .admin-int-spec-foot {
378 padding: 10px 16px;
379 border-top: 1px solid #e5e7eb;
380 background: #f9fafb;
381 font-size: 12px;
382 color: #6b7280;
383 }
384 .admin-int-spec-foot code {
385 background: #eef2ff;
386 color: #4338ca;
387 padding: 1px 6px;
388 border-radius: 4px;
389 font-size: 11.5px;
390 }
291391`;
292392
293393interface GroupDef {
@@ -381,6 +481,35 @@ integrations.get("/admin/integrations", async (c) => {
381481
382482 const totalConfigured = values.filter((v) => v.value.trim().length > 0).length;
383483
484 // Build the copyable .env spec — same key order as the form, grouped by
485 // section, with placeholders for unset keys. Real secrets are NOT inlined
486 // here (mask them); operators paste this into /etc/gluecron.env and fill
487 // in the blanks. Lines are joined with \n; the inline copy JS reads
488 // textContent so the rendered string is exactly what the operator pastes.
489 const specLines: string[] = [];
490 specLines.push("# Gluecron platform integrations");
491 specLines.push("# Generated from /admin/integrations — paste into /etc/gluecron.env");
492 specLines.push("");
493 for (const gid of groupOrder) {
494 const items = groups.get(gid);
495 if (!items || items.length === 0) continue;
496 const g = GROUPS[gid]!;
497 specLines.push(`# ─── ${g.title} ───`);
498 specLines.push(`# ${g.blurb}`);
499 for (const { field, value } of items) {
500 const v = value.trim();
501 if (field.isSecret) {
502 // Never leak the real secret into the spec — always a placeholder
503 // for paste-and-fill.
504 specLines.push(`${field.key}=${v ? "<unchanged — already set>" : `<paste-${field.key.toLowerCase()}>`}`);
505 } else {
506 specLines.push(`${field.key}=${v || `<set-${field.key.toLowerCase()}>`}`);
507 }
508 }
509 specLines.push("");
510 }
511 const specText = specLines.join("\n").trimEnd();
512
384513 return c.html(
385514 <Layout title="Integrations — admin" user={user}>
386515 <div class="admin-int-wrap">
@@ -411,6 +540,36 @@ integrations.get("/admin/integrations", async (c) => {
411540 </div>
412541 )}
413542
543 <section class="admin-int-spec" aria-labelledby="env-spec-title">
544 <header class="admin-int-spec-head">
545 <div>
546 <p class="admin-int-spec-title" id="env-spec-title">
547 <span class="admin-int-spec-title-dot" aria-hidden="true" />
548 .env spec
549 </p>
550 <span class="admin-int-spec-sub">
551 Copy top-to-bottom, paste into <code style="font-family:var(--font-mono);background:#eef2ff;color:#4338ca;padding:1px 5px;border-radius:4px;font-size:11.5px">/etc/gluecron.env</code>, fill in the placeholders.
552 </span>
553 </div>
554 <button
555 type="button"
556 class="admin-int-spec-copy"
557 data-spec-copy
558 aria-label="Copy .env spec to clipboard"
559 >
560 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
561 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
562 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
563 </svg>
564 <span data-spec-copy-label>Copy</span>
565 </button>
566 </header>
567 <pre class="admin-int-spec-pre" data-spec-text>{specText}</pre>
568 <div class="admin-int-spec-foot">
569 Reload after editing the env file: <code>sudo systemctl restart gluecron</code> · or save inline below for no-restart updates.
570 </div>
571 </section>
572
414573 <form method="post" action="/admin/integrations">
415574 {groupOrder.map((gid) => {
416575 const items = groups.get(gid);
@@ -502,6 +661,45 @@ integrations.get("/admin/integrations", async (c) => {
502661 </div>
503662 </div>
504663 <style dangerouslySetInnerHTML={{ __html: styles }} />
664 <script
665 dangerouslySetInnerHTML={{
666 __html: `
667 (function(){
668 var btn = document.querySelector('[data-spec-copy]');
669 var pre = document.querySelector('[data-spec-text]');
670 var label = document.querySelector('[data-spec-copy-label]');
671 if (!btn || !pre || !label) return;
672 btn.addEventListener('click', function(){
673 var text = pre.textContent || '';
674 var done = function(){
675 btn.classList.add('is-copied');
676 label.textContent = 'Copied';
677 setTimeout(function(){
678 btn.classList.remove('is-copied');
679 label.textContent = 'Copy';
680 }, 1800);
681 };
682 if (navigator.clipboard && navigator.clipboard.writeText) {
683 navigator.clipboard.writeText(text).then(done).catch(function(){
684 // Fallback for older browsers / non-secure contexts
685 var ta = document.createElement('textarea');
686 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
687 document.body.appendChild(ta); ta.select();
688 try { document.execCommand('copy'); done(); } catch(e){}
689 document.body.removeChild(ta);
690 });
691 } else {
692 var ta = document.createElement('textarea');
693 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
694 document.body.appendChild(ta); ta.select();
695 try { document.execCommand('copy'); done(); } catch(e){}
696 document.body.removeChild(ta);
697 }
698 });
699 })();
700 `,
701 }}
702 />
505703 </Layout>
506704 );
507705});
508706