Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
Blame · Line-by-line history

admin-integrations.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

admin-integrations.tsxBlame770 lines · 1 contributor
509c376Claude1/**
2 * /admin/integrations — DB-stored platform integration secrets.
3 *
4 * GET /admin/integrations — render the form (masked values)
5 * POST /admin/integrations — upsert each field + audit-log every change
6 *
7 * Replaces the SSH-into-the-box workflow for runtime-changeable keys
8 * (ANTHROPIC_API_KEY, RESEND_API_KEY, GITHUB_TOKEN, etc.). Boot hook in
9 * `src/index.ts` loads saved rows into `process.env` BEFORE any other
10 * module reads them, so existing synchronous `config.X` getters keep
11 * working transparently — no restart needed.
12 *
13 * Gated by `isSiteAdmin` using the same `gate()` pattern as
14 * `src/routes/admin.tsx`. Scoped CSS prefixed `.admin-int-` to avoid
15 * collisions with the parent admin polish.
16 */
17
18import { Hono } from "hono";
19import { Layout } from "../views/layout";
20import { softAuth } from "../middleware/auth";
21import type { AuthEnv } from "../middleware/auth";
22import { isSiteAdmin } from "../lib/admin";
23import { audit } from "../lib/notify";
24import {
25 getConfigValue,
26 setConfigValue,
27 maskSecret,
28 isMaskedValue,
29 INTEGRATION_FIELDS,
30} from "../lib/system-config";
31
32const integrations = new Hono<AuthEnv>();
33integrations.use("*", softAuth);
34
35/* ─────────────────────────────────────────────────────────────────────────
36 * Scoped CSS — every class prefixed `.admin-int-` so this surface can't
37 * bleed into the wider admin panel. Mirrors the gradient-hairline hero +
38 * card patterns from commits 07f4b70 and 98eb360.
39 * ───────────────────────────────────────────────────────────────────── */
40const styles = `
41 .admin-int-wrap { max-width: 920px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
42
43 .admin-int-hero {
44 position: relative;
45 margin-bottom: var(--space-5);
46 padding: var(--space-5) var(--space-6);
47 background: var(--bg-elevated);
48 border: 1px solid var(--border);
49 border-radius: 16px;
50 overflow: hidden;
51 }
52 .admin-int-hero::before {
53 content: '';
54 position: absolute;
55 top: 0; left: 0; right: 0;
56 height: 2px;
57 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
58 opacity: 0.7;
59 pointer-events: none;
60 }
61 .admin-int-hero-orb {
62 position: absolute;
63 inset: -20% -10% auto auto;
64 width: 380px; height: 380px;
65 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
66 filter: blur(80px);
67 opacity: 0.7;
68 pointer-events: none;
69 z-index: 0;
70 }
71 .admin-int-hero-inner { position: relative; z-index: 1; max-width: 720px; }
72 .admin-int-eyebrow {
73 font-size: 12px;
74 color: var(--text-muted);
75 margin-bottom: var(--space-2);
76 letter-spacing: 0.02em;
77 display: inline-flex;
78 align-items: center;
79 gap: 8px;
80 }
81 .admin-int-eyebrow .pill {
82 display: inline-flex;
83 align-items: center;
84 justify-content: center;
85 width: 18px; height: 18px;
86 border-radius: 6px;
87 background: rgba(140,109,255,0.14);
88 color: #b69dff;
89 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
90 }
91 .admin-int-title {
92 font-size: clamp(28px, 4vw, 40px);
93 font-family: var(--font-display);
94 font-weight: 800;
95 letter-spacing: -0.028em;
96 line-height: 1.05;
97 margin: 0 0 var(--space-2);
98 color: var(--text-strong);
99 }
100 .admin-int-title-grad {
101 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
102 -webkit-background-clip: text;
103 background-clip: text;
104 -webkit-text-fill-color: transparent;
105 color: transparent;
106 }
107 .admin-int-sub {
108 font-size: 15px;
109 color: var(--text-muted);
110 margin: 0;
111 line-height: 1.5;
112 max-width: 620px;
113 }
114
115 .admin-int-banner {
116 margin-bottom: var(--space-4);
117 padding: 10px 14px;
118 border-radius: 10px;
119 font-size: 13.5px;
120 border: 1px solid var(--border);
121 background: rgba(255,255,255,0.025);
122 color: var(--text);
123 }
124 .admin-int-banner.is-ok {
125 border-color: rgba(52,211,153,0.40);
126 background: rgba(52,211,153,0.08);
127 color: #bbf7d0;
128 }
129 .admin-int-banner.is-error {
130 border-color: rgba(248,113,113,0.40);
131 background: rgba(248,113,113,0.08);
132 color: #fecaca;
133 }
134
135 .admin-int-section {
136 margin-bottom: var(--space-5);
137 background: var(--bg-elevated);
138 border: 1px solid var(--border);
139 border-radius: 14px;
140 overflow: hidden;
141 }
142 .admin-int-section-head {
143 padding: var(--space-4) var(--space-5);
144 border-bottom: 1px solid var(--border);
145 display: flex;
146 align-items: center;
147 justify-content: space-between;
148 gap: var(--space-3);
149 flex-wrap: wrap;
150 }
151 .admin-int-section-title {
152 margin: 0;
153 font-family: var(--font-display);
154 font-size: 17px;
155 font-weight: 700;
156 letter-spacing: -0.018em;
157 color: var(--text-strong);
158 }
159 .admin-int-section-sub {
160 margin: 4px 0 0;
161 font-size: 12.5px;
162 color: var(--text-muted);
163 }
164 .admin-int-section-body { padding: var(--space-4) var(--space-5); }
165
166 .admin-int-field { margin-bottom: var(--space-4); }
167 .admin-int-field:last-child { margin-bottom: 0; }
168 .admin-int-field-row {
169 display: flex;
170 align-items: center;
171 justify-content: space-between;
172 gap: var(--space-2);
173 margin-bottom: 6px;
174 }
175 .admin-int-field label {
176 display: block;
177 font-family: var(--font-mono);
178 font-size: 12.5px;
179 font-weight: 600;
180 color: var(--text-strong);
181 letter-spacing: -0.005em;
182 }
183 .admin-int-input {
184 width: 100%;
185 padding: 9px 12px;
186 font-size: 13.5px;
187 color: var(--text);
188 background: var(--bg);
189 border: 1px solid var(--border-strong);
190 border-radius: 8px;
191 outline: none;
192 font-family: var(--font-mono);
193 transition: border-color 120ms ease, box-shadow 120ms ease;
194 box-sizing: border-box;
195 }
196 .admin-int-input:focus {
197 border-color: var(--border-focus);
198 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
199 }
200 .admin-int-hint {
201 font-size: 11.5px;
202 color: var(--text-muted);
203 margin-top: 6px;
204 line-height: 1.45;
205 }
206 .admin-int-hint code {
207 font-family: var(--font-mono);
208 font-size: 11.5px;
209 background: var(--bg-tertiary);
210 padding: 1px 5px;
211 border-radius: 4px;
212 }
213 .admin-int-hint a { color: var(--accent); text-decoration: none; }
214 .admin-int-hint a:hover { text-decoration: underline; }
215
216 .admin-int-status {
217 display: inline-flex;
218 align-items: center;
219 gap: 4px;
220 padding: 2px 8px;
221 border-radius: 9999px;
222 font-size: 10.5px;
223 font-weight: 600;
224 letter-spacing: 0.04em;
225 text-transform: uppercase;
226 }
227 .admin-int-status.is-set {
228 background: rgba(52,211,153,0.14);
229 color: #6ee7b7;
230 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
231 }
232 .admin-int-status.is-missing {
233 background: rgba(251,191,36,0.10);
234 color: #fde68a;
235 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
236 }
237 .admin-int-status .dot {
238 width: 6px; height: 6px;
239 border-radius: 9999px;
240 background: currentColor;
241 }
242
243 .admin-int-foot {
244 padding: var(--space-3) var(--space-5);
245 border-top: 1px solid var(--border);
246 background: rgba(255,255,255,0.012);
247 display: flex;
248 justify-content: flex-end;
249 gap: var(--space-2);
250 align-items: center;
251 flex-wrap: wrap;
252 }
253 .admin-int-foot-hint {
254 margin-right: auto;
255 font-size: 12.5px;
256 color: var(--text-muted);
257 }
258
259 .admin-int-bottom-actions {
260 margin-top: var(--space-5);
261 padding: var(--space-4);
262 text-align: center;
263 color: var(--text-muted);
264 font-size: 13px;
265 border: 1px dashed var(--border);
266 border-radius: 12px;
267 }
268 .admin-int-bottom-actions a {
269 color: var(--accent);
270 text-decoration: none;
271 font-weight: 600;
272 }
273 .admin-int-bottom-actions a:hover { text-decoration: underline; }
274
275 .admin-int-403 {
276 max-width: 540px;
277 margin: var(--space-12) auto;
278 padding: var(--space-6);
279 text-align: center;
280 background: var(--bg-elevated);
281 border: 1px solid var(--border);
282 border-radius: 16px;
283 }
284 .admin-int-403 h2 {
285 font-family: var(--font-display);
286 font-size: 22px;
287 margin: 0 0 8px;
288 color: var(--text-strong);
289 }
290 .admin-int-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
c5ab657Claude291
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 }
509c376Claude391`;
392
393interface GroupDef {
394 id: string;
395 title: string;
396 blurb: string;
397}
398
399const GROUPS: Record<string, GroupDef> = {
400 ai: {
401 id: "ai",
402 title: "AI",
403 blurb: "Anthropic — powers PR review, incident response, commit messages.",
404 },
405 email: {
406 id: "email",
407 title: "Email",
408 blurb: "Verification, password reset, and magic-link delivery.",
409 },
410 scm: {
411 id: "scm",
412 title: "Source control",
413 blurb: "GitHub-side API calls (mirror sync, auto-merge sweep).",
414 },
415 security: {
416 id: "security",
417 title: "Security",
418 blurb: "Push-time security scanning via GateTest.",
419 },
420 observability: {
421 id: "observability",
422 title: "Observability",
423 blurb: "Deploy timeline + AI incident responder.",
424 },
425 webhook: {
426 id: "webhook",
427 title: "Outbound webhooks",
428 blurb: "Optional notifications to downstream platforms.",
429 },
430};
431
432async function gate(c: any): Promise<{ user: any } | Response> {
433 const user = c.get("user");
434 if (!user) return c.redirect("/login?next=/admin/integrations");
435 if (!(await isSiteAdmin(user.id))) {
436 return c.html(
437 <Layout title="Forbidden" user={user}>
438 <div class="admin-int-403">
439 <h2>403 — Not a site admin</h2>
440 <p>You don't have permission to view this page.</p>
441 </div>
442 <style dangerouslySetInnerHTML={{ __html: styles }} />
443 </Layout>,
444 403
445 );
446 }
447 return { user };
448}
449
450integrations.get("/admin/integrations", async (c) => {
451 const g = await gate(c);
452 if (g instanceof Response) return g;
453 const { user } = g;
454
455 // Load every field's current value (DB → env → empty). Run in parallel.
456 const values = await Promise.all(
457 INTEGRATION_FIELDS.map(async (f) => ({
458 field: f,
459 value: await getConfigValue(f.key, f.envFallback),
460 }))
461 );
462
463 const groups = new Map<string, typeof values>();
464 for (const v of values) {
465 const arr = groups.get(v.field.group) ?? [];
466 arr.push(v);
467 groups.set(v.field.group, arr);
468 }
469
470 const groupOrder: Array<keyof typeof GROUPS> = [
471 "ai",
472 "email",
473 "scm",
474 "security",
475 "observability",
476 "webhook",
477 ];
478
479 const msg = c.req.query("result") || c.req.query("error");
480 const isErr = !!c.req.query("error");
481
482 const totalConfigured = values.filter((v) => v.value.trim().length > 0).length;
483
c5ab657Claude484 // 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
509c376Claude513 return c.html(
514 <Layout title="Integrations — admin" user={user}>
515 <div class="admin-int-wrap">
516 <section class="admin-int-hero">
517 <div class="admin-int-hero-orb" aria-hidden="true" />
518 <div class="admin-int-hero-inner">
519 <div class="admin-int-eyebrow">
520 <span class="pill" aria-hidden="true">
521 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
522 <path d="M21 2l-2 2m-7.61 7.61a5.5 5.5 0 1 1-7.778 7.778 5.5 5.5 0 0 1 7.777-7.777zm0 0L15.5 7.5m0 0l3 3L22 7l-3-3m-3.5 3.5L19 4" />
523 </svg>
524 </span>
525 Platform integrations · Site admin · <span style="color:var(--accent);font-weight:600">{user.username}</span>
526 </div>
527 <h2 class="admin-int-title">
528 <span class="admin-int-title-grad">Wire it up.</span>
529 </h2>
530 <p class="admin-int-sub">
531 Every key you'd otherwise put in <code style="font-family:var(--font-mono);font-size:13px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">/etc/gluecron.env</code>.
532 Changes apply immediately — no restart. {totalConfigured} of {INTEGRATION_FIELDS.length} configured.
533 </p>
534 </div>
535 </section>
536
537 {msg && (
538 <div class={"admin-int-banner " + (isErr ? "is-error" : "is-ok")}>
539 {decodeURIComponent(msg)}
540 </div>
541 )}
542
c5ab657Claude543 <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
509c376Claude573 <form method="post" action="/admin/integrations">
574 {groupOrder.map((gid) => {
575 const items = groups.get(gid);
576 if (!items || items.length === 0) return null;
577 const g = GROUPS[gid]!;
578 return (
579 <section class="admin-int-section">
580 <header class="admin-int-section-head">
581 <div>
582 <h3 class="admin-int-section-title">{g.title}</h3>
583 <p class="admin-int-section-sub">{g.blurb}</p>
584 </div>
585 </header>
586 <div class="admin-int-section-body">
587 {items.map(({ field, value }) => {
588 const configured = value.trim().length > 0;
589 const display = field.isSecret && configured
590 ? maskSecret(value)
591 : value;
592 return (
593 <div class="admin-int-field">
594 <div class="admin-int-field-row">
595 <label for={`int-${field.key}`}>{field.key}</label>
596 <span
597 class={
598 "admin-int-status " +
599 (configured ? "is-set" : "is-missing")
600 }
601 >
602 <span class="dot" aria-hidden="true" />
603 {configured ? "configured" : "missing"}
604 </span>
605 </div>
606 <input
607 id={`int-${field.key}`}
608 type="text"
609 name={field.key}
610 value={display}
611 aria-label={field.label}
612 placeholder={
613 field.isSecret
614 ? "Paste the secret here"
615 : "Set a value"
616 }
617 class="admin-int-input"
618 autocomplete="off"
619 spellcheck={false}
620 />
621 <div class="admin-int-hint">
622 {field.helper}
623 {field.helperLink && (
624 <>
625 {" "}
626 <a
627 href={field.helperLink.href}
628 target="_blank"
629 rel="noopener noreferrer"
630 >
631 {field.helperLink.text} ↗
632 </a>
633 </>
634 )}
635 {" · env fallback: "}
636 <code>{field.envFallback}</code>
637 </div>
638 </div>
639 );
640 })}
641 </div>
642 </section>
643 );
644 })}
645
646 <div class="admin-int-section" style="margin-bottom:0">
647 <div class="admin-int-foot">
648 <span class="admin-int-foot-hint">
649 Values containing <code style="font-family:var(--font-mono);font-size:11.5px;background:var(--bg-tertiary);padding:1px 5px;border-radius:4px">••••••</code> are treated as unchanged — your real secret is preserved.
650 </span>
651 <button type="submit" class="btn btn-primary">
652 Save all changes
653 </button>
654 </div>
655 </div>
656 </form>
657
658 <div class="admin-int-bottom-actions">
659 Verify your changes turned the warnings green on{" "}
660 <a href="/admin/health">/admin/health</a>.
661 </div>
662 </div>
663 <style dangerouslySetInnerHTML={{ __html: styles }} />
c5ab657Claude664 <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 />
509c376Claude703 </Layout>
704 );
705});
706
707integrations.post("/admin/integrations", async (c) => {
708 const g = await gate(c);
709 if (g instanceof Response) return g;
710 const { user } = g;
711
712 const body = await c.req.parseBody();
713
714 let saved = 0;
715 let skipped = 0;
716 const errors: string[] = [];
717
718 for (const field of INTEGRATION_FIELDS) {
719 const submitted = String(body[field.key] ?? "").trim();
720
721 // Don't overwrite a real secret with the mask we showed in the form.
722 if (isMaskedValue(submitted)) {
723 skipped++;
724 continue;
725 }
726
727 // Read the current value to detect a no-op (avoids spurious audit rows).
728 const current = await getConfigValue(field.key, field.envFallback);
729 if (submitted === current) {
730 skipped++;
731 continue;
732 }
733
734 try {
735 await setConfigValue(field.key, submitted, user.id);
736 await audit({
737 userId: user.id,
738 action: "admin.integrations.save",
739 targetType: "system_config",
740 targetId: field.key,
741 // Audit the KEY name + whether a value is now set — NEVER the value.
742 metadata: {
743 key: field.key,
744 hadValue: current.length > 0,
745 hasValue: submitted.length > 0,
746 },
747 });
748 saved++;
749 } catch (err) {
750 const msg = err instanceof Error ? err.message : String(err);
751 errors.push(`${field.key}: ${msg}`);
752 }
753 }
754
755 if (errors.length > 0) {
756 return c.redirect(
757 `/admin/integrations?error=${encodeURIComponent(
758 `Saved ${saved}, but ${errors.length} failed: ${errors.join("; ")}`
759 )}`
760 );
761 }
762
763 const summary =
764 saved === 0
765 ? "No changes — every field matched the current value."
766 : `Saved ${saved} integration${saved === 1 ? "" : "s"}.${skipped > 0 ? ` ${skipped} unchanged.` : ""}`;
767 return c.redirect(`/admin/integrations?result=${encodeURIComponent(summary)}`);
768});
769
770export default integrations;