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.tsxBlame777 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 = `
eed4684Claude41 .admin-int-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
509c376Claude42
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> = {
06a502cClaude400 platform: {
401 id: "platform",
402 title: "Platform",
403 blurb:
404 "Public URL + self-host repo name. APP_BASE_URL must be right or OAuth fails with redirect_uri_mismatch.",
405 },
509c376Claude406 ai: {
407 id: "ai",
408 title: "AI",
409 blurb: "Anthropic — powers PR review, incident response, commit messages.",
410 },
411 email: {
412 id: "email",
413 title: "Email",
414 blurb: "Verification, password reset, and magic-link delivery.",
415 },
416 scm: {
417 id: "scm",
418 title: "Source control",
419 blurb: "GitHub-side API calls (mirror sync, auto-merge sweep).",
420 },
421 security: {
422 id: "security",
423 title: "Security",
424 blurb: "Push-time security scanning via GateTest.",
425 },
426 observability: {
427 id: "observability",
428 title: "Observability",
429 blurb: "Deploy timeline + AI incident responder.",
430 },
431 webhook: {
432 id: "webhook",
433 title: "Outbound webhooks",
434 blurb: "Optional notifications to downstream platforms.",
435 },
436};
437
438async function gate(c: any): Promise<{ user: any } | Response> {
439 const user = c.get("user");
440 if (!user) return c.redirect("/login?next=/admin/integrations");
441 if (!(await isSiteAdmin(user.id))) {
442 return c.html(
443 <Layout title="Forbidden" user={user}>
444 <div class="admin-int-403">
445 <h2>403 — Not a site admin</h2>
446 <p>You don't have permission to view this page.</p>
447 </div>
448 <style dangerouslySetInnerHTML={{ __html: styles }} />
449 </Layout>,
450 403
451 );
452 }
453 return { user };
454}
455
456integrations.get("/admin/integrations", async (c) => {
457 const g = await gate(c);
458 if (g instanceof Response) return g;
459 const { user } = g;
460
461 // Load every field's current value (DB → env → empty). Run in parallel.
462 const values = await Promise.all(
463 INTEGRATION_FIELDS.map(async (f) => ({
464 field: f,
465 value: await getConfigValue(f.key, f.envFallback),
466 }))
467 );
468
469 const groups = new Map<string, typeof values>();
470 for (const v of values) {
471 const arr = groups.get(v.field.group) ?? [];
472 arr.push(v);
473 groups.set(v.field.group, arr);
474 }
475
476 const groupOrder: Array<keyof typeof GROUPS> = [
06a502cClaude477 "platform",
509c376Claude478 "ai",
479 "email",
480 "scm",
481 "security",
482 "observability",
483 "webhook",
484 ];
485
486 const msg = c.req.query("result") || c.req.query("error");
487 const isErr = !!c.req.query("error");
488
489 const totalConfigured = values.filter((v) => v.value.trim().length > 0).length;
490
c5ab657Claude491 // Build the copyable .env spec — same key order as the form, grouped by
492 // section, with placeholders for unset keys. Real secrets are NOT inlined
493 // here (mask them); operators paste this into /etc/gluecron.env and fill
494 // in the blanks. Lines are joined with \n; the inline copy JS reads
495 // textContent so the rendered string is exactly what the operator pastes.
496 const specLines: string[] = [];
497 specLines.push("# Gluecron platform integrations");
498 specLines.push("# Generated from /admin/integrations — paste into /etc/gluecron.env");
499 specLines.push("");
500 for (const gid of groupOrder) {
501 const items = groups.get(gid);
502 if (!items || items.length === 0) continue;
503 const g = GROUPS[gid]!;
504 specLines.push(`# ─── ${g.title} ───`);
505 specLines.push(`# ${g.blurb}`);
506 for (const { field, value } of items) {
507 const v = value.trim();
508 if (field.isSecret) {
509 // Never leak the real secret into the spec — always a placeholder
510 // for paste-and-fill.
511 specLines.push(`${field.key}=${v ? "<unchanged — already set>" : `<paste-${field.key.toLowerCase()}>`}`);
512 } else {
513 specLines.push(`${field.key}=${v || `<set-${field.key.toLowerCase()}>`}`);
514 }
515 }
516 specLines.push("");
517 }
518 const specText = specLines.join("\n").trimEnd();
519
509c376Claude520 return c.html(
521 <Layout title="Integrations — admin" user={user}>
522 <div class="admin-int-wrap">
523 <section class="admin-int-hero">
524 <div class="admin-int-hero-orb" aria-hidden="true" />
525 <div class="admin-int-hero-inner">
526 <div class="admin-int-eyebrow">
527 <span class="pill" aria-hidden="true">
528 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
529 <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" />
530 </svg>
531 </span>
532 Platform integrations · Site admin · <span style="color:var(--accent);font-weight:600">{user.username}</span>
533 </div>
534 <h2 class="admin-int-title">
535 <span class="admin-int-title-grad">Wire it up.</span>
536 </h2>
537 <p class="admin-int-sub">
538 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>.
539 Changes apply immediately — no restart. {totalConfigured} of {INTEGRATION_FIELDS.length} configured.
540 </p>
541 </div>
542 </section>
543
544 {msg && (
545 <div class={"admin-int-banner " + (isErr ? "is-error" : "is-ok")}>
546 {decodeURIComponent(msg)}
547 </div>
548 )}
549
c5ab657Claude550 <section class="admin-int-spec" aria-labelledby="env-spec-title">
551 <header class="admin-int-spec-head">
552 <div>
553 <p class="admin-int-spec-title" id="env-spec-title">
554 <span class="admin-int-spec-title-dot" aria-hidden="true" />
555 .env spec
556 </p>
557 <span class="admin-int-spec-sub">
558 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.
559 </span>
560 </div>
561 <button
562 type="button"
563 class="admin-int-spec-copy"
564 data-spec-copy
565 aria-label="Copy .env spec to clipboard"
566 >
567 <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">
568 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
569 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
570 </svg>
571 <span data-spec-copy-label>Copy</span>
572 </button>
573 </header>
574 <pre class="admin-int-spec-pre" data-spec-text>{specText}</pre>
575 <div class="admin-int-spec-foot">
576 Reload after editing the env file: <code>sudo systemctl restart gluecron</code> · or save inline below for no-restart updates.
577 </div>
578 </section>
579
509c376Claude580 <form method="post" action="/admin/integrations">
581 {groupOrder.map((gid) => {
582 const items = groups.get(gid);
583 if (!items || items.length === 0) return null;
584 const g = GROUPS[gid]!;
585 return (
586 <section class="admin-int-section">
587 <header class="admin-int-section-head">
588 <div>
589 <h3 class="admin-int-section-title">{g.title}</h3>
590 <p class="admin-int-section-sub">{g.blurb}</p>
591 </div>
592 </header>
593 <div class="admin-int-section-body">
594 {items.map(({ field, value }) => {
595 const configured = value.trim().length > 0;
596 const display = field.isSecret && configured
597 ? maskSecret(value)
598 : value;
599 return (
600 <div class="admin-int-field">
601 <div class="admin-int-field-row">
602 <label for={`int-${field.key}`}>{field.key}</label>
603 <span
604 class={
605 "admin-int-status " +
606 (configured ? "is-set" : "is-missing")
607 }
608 >
609 <span class="dot" aria-hidden="true" />
610 {configured ? "configured" : "missing"}
611 </span>
612 </div>
613 <input
614 id={`int-${field.key}`}
615 type="text"
616 name={field.key}
617 value={display}
618 aria-label={field.label}
619 placeholder={
620 field.isSecret
621 ? "Paste the secret here"
622 : "Set a value"
623 }
624 class="admin-int-input"
625 autocomplete="off"
626 spellcheck={false}
627 />
628 <div class="admin-int-hint">
629 {field.helper}
630 {field.helperLink && (
631 <>
632 {" "}
633 <a
634 href={field.helperLink.href}
635 target="_blank"
636 rel="noopener noreferrer"
637 >
638 {field.helperLink.text} ↗
639 </a>
640 </>
641 )}
642 {" · env fallback: "}
643 <code>{field.envFallback}</code>
644 </div>
645 </div>
646 );
647 })}
648 </div>
649 </section>
650 );
651 })}
652
653 <div class="admin-int-section" style="margin-bottom:0">
654 <div class="admin-int-foot">
655 <span class="admin-int-foot-hint">
656 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.
657 </span>
658 <button type="submit" class="btn btn-primary">
659 Save all changes
660 </button>
661 </div>
662 </div>
663 </form>
664
665 <div class="admin-int-bottom-actions">
666 Verify your changes turned the warnings green on{" "}
667 <a href="/admin/health">/admin/health</a>.
668 </div>
669 </div>
670 <style dangerouslySetInnerHTML={{ __html: styles }} />
c5ab657Claude671 <script
672 dangerouslySetInnerHTML={{
673 __html: `
674 (function(){
675 var btn = document.querySelector('[data-spec-copy]');
676 var pre = document.querySelector('[data-spec-text]');
677 var label = document.querySelector('[data-spec-copy-label]');
678 if (!btn || !pre || !label) return;
679 btn.addEventListener('click', function(){
680 var text = pre.textContent || '';
681 var done = function(){
682 btn.classList.add('is-copied');
683 label.textContent = 'Copied';
684 setTimeout(function(){
685 btn.classList.remove('is-copied');
686 label.textContent = 'Copy';
687 }, 1800);
688 };
689 if (navigator.clipboard && navigator.clipboard.writeText) {
690 navigator.clipboard.writeText(text).then(done).catch(function(){
691 // Fallback for older browsers / non-secure contexts
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 } else {
699 var ta = document.createElement('textarea');
700 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
701 document.body.appendChild(ta); ta.select();
702 try { document.execCommand('copy'); done(); } catch(e){}
703 document.body.removeChild(ta);
704 }
705 });
706 })();
707 `,
708 }}
709 />
509c376Claude710 </Layout>
711 );
712});
713
714integrations.post("/admin/integrations", async (c) => {
715 const g = await gate(c);
716 if (g instanceof Response) return g;
717 const { user } = g;
718
719 const body = await c.req.parseBody();
720
721 let saved = 0;
722 let skipped = 0;
723 const errors: string[] = [];
724
725 for (const field of INTEGRATION_FIELDS) {
726 const submitted = String(body[field.key] ?? "").trim();
727
728 // Don't overwrite a real secret with the mask we showed in the form.
729 if (isMaskedValue(submitted)) {
730 skipped++;
731 continue;
732 }
733
734 // Read the current value to detect a no-op (avoids spurious audit rows).
735 const current = await getConfigValue(field.key, field.envFallback);
736 if (submitted === current) {
737 skipped++;
738 continue;
739 }
740
741 try {
742 await setConfigValue(field.key, submitted, user.id);
743 await audit({
744 userId: user.id,
745 action: "admin.integrations.save",
746 targetType: "system_config",
747 targetId: field.key,
748 // Audit the KEY name + whether a value is now set — NEVER the value.
749 metadata: {
750 key: field.key,
751 hadValue: current.length > 0,
752 hasValue: submitted.length > 0,
753 },
754 });
755 saved++;
756 } catch (err) {
757 const msg = err instanceof Error ? err.message : String(err);
758 errors.push(`${field.key}: ${msg}`);
759 }
760 }
761
762 if (errors.length > 0) {
763 return c.redirect(
764 `/admin/integrations?error=${encodeURIComponent(
765 `Saved ${saved}, but ${errors.length} failed: ${errors.join("; ")}`
766 )}`
767 );
768 }
769
770 const summary =
771 saved === 0
772 ? "No changes — every field matched the current value."
773 : `Saved ${saved} integration${saved === 1 ? "" : "s"}.${skipped > 0 ? ` ${skipped} unchanged.` : ""}`;
774 return c.redirect(`/admin/integrations?result=${encodeURIComponent(summary)}`);
775});
776
777export default integrations;