Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
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.tsxBlame778 lines · 2 contributors
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";
f4a1547ccantynz-alt20import { AdminShell } from "../views/admin-shell";
509c376Claude21import { softAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { isSiteAdmin } from "../lib/admin";
24import { audit } from "../lib/notify";
25import {
26 getConfigValue,
27 setConfigValue,
28 maskSecret,
29 isMaskedValue,
30 INTEGRATION_FIELDS,
31} from "../lib/system-config";
32
33const integrations = new Hono<AuthEnv>();
34integrations.use("*", softAuth);
35
36/* ─────────────────────────────────────────────────────────────────────────
37 * Scoped CSS — every class prefixed `.admin-int-` so this surface can't
38 * bleed into the wider admin panel. Mirrors the gradient-hairline hero +
39 * card patterns from commits 07f4b70 and 98eb360.
40 * ───────────────────────────────────────────────────────────────────── */
41const styles = `
eed4684Claude42 .admin-int-wrap { max-width: 1320px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
509c376Claude43
44 .admin-int-hero {
45 position: relative;
46 margin-bottom: var(--space-5);
47 padding: var(--space-5) var(--space-6);
48 background: var(--bg-elevated);
49 border: 1px solid var(--border);
50 border-radius: 16px;
51 overflow: hidden;
52 }
53 .admin-int-hero::before {
54 content: '';
55 position: absolute;
56 top: 0; left: 0; right: 0;
57 height: 2px;
6fd5915Claude58 background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%);
509c376Claude59 opacity: 0.7;
60 pointer-events: none;
61 }
62 .admin-int-hero-orb {
63 position: absolute;
64 inset: -20% -10% auto auto;
65 width: 380px; height: 380px;
6fd5915Claude66 background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%);
509c376Claude67 filter: blur(80px);
68 opacity: 0.7;
69 pointer-events: none;
70 z-index: 0;
71 }
72 .admin-int-hero-inner { position: relative; z-index: 1; max-width: 720px; }
73 .admin-int-eyebrow {
74 font-size: 12px;
75 color: var(--text-muted);
76 margin-bottom: var(--space-2);
77 letter-spacing: 0.02em;
78 display: inline-flex;
79 align-items: center;
80 gap: 8px;
81 }
82 .admin-int-eyebrow .pill {
83 display: inline-flex;
84 align-items: center;
85 justify-content: center;
86 width: 18px; height: 18px;
87 border-radius: 6px;
6fd5915Claude88 background: rgba(91,110,232,0.14);
e589f77ccantynz-alt89 color: var(--accent);
6fd5915Claude90 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
509c376Claude91 }
92 .admin-int-title {
93 font-size: clamp(28px, 4vw, 40px);
94 font-family: var(--font-display);
95 font-weight: 800;
96 letter-spacing: -0.028em;
97 line-height: 1.05;
98 margin: 0 0 var(--space-2);
99 color: var(--text-strong);
100 }
101 .admin-int-title-grad {
6fd5915Claude102 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
509c376Claude103 -webkit-background-clip: text;
104 background-clip: text;
105 -webkit-text-fill-color: transparent;
106 color: transparent;
107 }
108 .admin-int-sub {
109 font-size: 15px;
110 color: var(--text-muted);
111 margin: 0;
112 line-height: 1.5;
113 max-width: 620px;
114 }
115
116 .admin-int-banner {
117 margin-bottom: var(--space-4);
118 padding: 10px 14px;
119 border-radius: 10px;
120 font-size: 13.5px;
121 border: 1px solid var(--border);
122 background: rgba(255,255,255,0.025);
123 color: var(--text);
124 }
125 .admin-int-banner.is-ok {
126 border-color: rgba(52,211,153,0.40);
127 background: rgba(52,211,153,0.08);
128 color: #bbf7d0;
129 }
130 .admin-int-banner.is-error {
131 border-color: rgba(248,113,113,0.40);
132 background: rgba(248,113,113,0.08);
133 color: #fecaca;
134 }
135
136 .admin-int-section {
137 margin-bottom: var(--space-5);
138 background: var(--bg-elevated);
139 border: 1px solid var(--border);
140 border-radius: 14px;
141 overflow: hidden;
142 }
143 .admin-int-section-head {
144 padding: var(--space-4) var(--space-5);
145 border-bottom: 1px solid var(--border);
146 display: flex;
147 align-items: center;
148 justify-content: space-between;
149 gap: var(--space-3);
150 flex-wrap: wrap;
151 }
152 .admin-int-section-title {
153 margin: 0;
154 font-family: var(--font-display);
155 font-size: 17px;
156 font-weight: 700;
157 letter-spacing: -0.018em;
158 color: var(--text-strong);
159 }
160 .admin-int-section-sub {
161 margin: 4px 0 0;
162 font-size: 12.5px;
163 color: var(--text-muted);
164 }
165 .admin-int-section-body { padding: var(--space-4) var(--space-5); }
166
167 .admin-int-field { margin-bottom: var(--space-4); }
168 .admin-int-field:last-child { margin-bottom: 0; }
169 .admin-int-field-row {
170 display: flex;
171 align-items: center;
172 justify-content: space-between;
173 gap: var(--space-2);
174 margin-bottom: 6px;
175 }
176 .admin-int-field label {
177 display: block;
178 font-family: var(--font-mono);
179 font-size: 12.5px;
180 font-weight: 600;
181 color: var(--text-strong);
182 letter-spacing: -0.005em;
183 }
184 .admin-int-input {
185 width: 100%;
186 padding: 9px 12px;
187 font-size: 13.5px;
188 color: var(--text);
189 background: var(--bg);
190 border: 1px solid var(--border-strong);
191 border-radius: 8px;
192 outline: none;
193 font-family: var(--font-mono);
194 transition: border-color 120ms ease, box-shadow 120ms ease;
195 box-sizing: border-box;
196 }
197 .admin-int-input:focus {
198 border-color: var(--border-focus);
6fd5915Claude199 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
509c376Claude200 }
201 .admin-int-hint {
202 font-size: 11.5px;
203 color: var(--text-muted);
204 margin-top: 6px;
205 line-height: 1.45;
206 }
207 .admin-int-hint code {
208 font-family: var(--font-mono);
209 font-size: 11.5px;
210 background: var(--bg-tertiary);
211 padding: 1px 5px;
212 border-radius: 4px;
213 }
214 .admin-int-hint a { color: var(--accent); text-decoration: none; }
215 .admin-int-hint a:hover { text-decoration: underline; }
216
217 .admin-int-status {
218 display: inline-flex;
219 align-items: center;
220 gap: 4px;
221 padding: 2px 8px;
222 border-radius: 9999px;
223 font-size: 10.5px;
224 font-weight: 600;
225 letter-spacing: 0.04em;
226 text-transform: uppercase;
227 }
228 .admin-int-status.is-set {
229 background: rgba(52,211,153,0.14);
e589f77ccantynz-alt230 color: var(--green);
509c376Claude231 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
232 }
233 .admin-int-status.is-missing {
234 background: rgba(251,191,36,0.10);
235 color: #fde68a;
236 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
237 }
238 .admin-int-status .dot {
239 width: 6px; height: 6px;
240 border-radius: 9999px;
241 background: currentColor;
242 }
243
244 .admin-int-foot {
245 padding: var(--space-3) var(--space-5);
246 border-top: 1px solid var(--border);
247 background: rgba(255,255,255,0.012);
248 display: flex;
249 justify-content: flex-end;
250 gap: var(--space-2);
251 align-items: center;
252 flex-wrap: wrap;
253 }
254 .admin-int-foot-hint {
255 margin-right: auto;
256 font-size: 12.5px;
257 color: var(--text-muted);
258 }
259
260 .admin-int-bottom-actions {
261 margin-top: var(--space-5);
262 padding: var(--space-4);
263 text-align: center;
264 color: var(--text-muted);
265 font-size: 13px;
266 border: 1px dashed var(--border);
267 border-radius: 12px;
268 }
269 .admin-int-bottom-actions a {
270 color: var(--accent);
271 text-decoration: none;
272 font-weight: 600;
273 }
274 .admin-int-bottom-actions a:hover { text-decoration: underline; }
275
276 .admin-int-403 {
277 max-width: 540px;
278 margin: var(--space-12) auto;
279 padding: var(--space-6);
280 text-align: center;
281 background: var(--bg-elevated);
282 border: 1px solid var(--border);
283 border-radius: 16px;
284 }
285 .admin-int-403 h2 {
286 font-family: var(--font-display);
287 font-size: 22px;
288 margin: 0 0 8px;
289 color: var(--text-strong);
290 }
291 .admin-int-403 p { color: var(--text-muted); margin: 0; font-size: 14px; }
c5ab657Claude292
293 /* Solid white .env spec block — high-contrast block the operator copies
294 and pastes into their /etc/gluecron.env file. Intentionally light so
295 it reads like a printed spec on the dark admin page. */
296 .admin-int-spec {
297 margin-bottom: var(--space-5);
298 background: #ffffff;
299 color: #0a0a0a;
300 border: 1px solid #e5e7eb;
301 border-radius: 14px;
302 overflow: hidden;
303 box-shadow: 0 1px 0 rgba(255,255,255,0.04), 0 8px 32px rgba(0,0,0,0.18);
304 }
305 .admin-int-spec-head {
306 display: flex;
307 align-items: center;
308 justify-content: space-between;
309 gap: 12px;
310 padding: 12px 16px;
311 background: #f9fafb;
312 border-bottom: 1px solid #e5e7eb;
313 flex-wrap: wrap;
314 }
315 .admin-int-spec-title {
316 display: flex;
317 align-items: center;
318 gap: 10px;
319 font-family: var(--font-display, system-ui, sans-serif);
320 font-size: 14px;
321 font-weight: 700;
322 color: #111827;
323 letter-spacing: -0.005em;
324 margin: 0;
325 }
326 .admin-int-spec-title-dot {
327 width: 8px; height: 8px;
328 border-radius: 9999px;
6fd5915Claude329 background: linear-gradient(135deg, #5b6ee8, #5f8fa0);
330 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
c5ab657Claude331 }
332 .admin-int-spec-sub {
333 font-size: 12px;
334 color: #6b7280;
335 margin-left: 16px;
336 }
337 .admin-int-spec-copy {
338 display: inline-flex;
339 align-items: center;
340 gap: 6px;
341 padding: 6px 12px;
342 font-size: 12.5px;
343 font-weight: 600;
344 color: #111827;
345 background: #ffffff;
346 border: 1px solid #d1d5db;
347 border-radius: 8px;
348 cursor: pointer;
349 font-family: inherit;
350 transition: background 120ms ease, border-color 120ms ease, color 120ms ease;
351 }
352 .admin-int-spec-copy:hover {
353 background: #f3f4f6;
354 border-color: #9ca3af;
355 }
356 .admin-int-spec-copy.is-copied {
357 background: #ecfdf5;
e589f77ccantynz-alt358 border-color: var(--green);
c5ab657Claude359 color: #047857;
360 }
361 .admin-int-spec-copy svg { display: block; }
362 .admin-int-spec-pre {
363 margin: 0;
364 padding: 18px 20px;
365 font-family: var(--font-mono, ui-monospace, "SF Mono", Menlo, monospace);
366 font-size: 13px;
367 line-height: 1.7;
368 color: #0a0a0a;
369 background: #ffffff;
370 white-space: pre;
371 overflow-x: auto;
372 tab-size: 2;
373 }
374 .admin-int-spec-pre .c { color: #6b7280; }
375 .admin-int-spec-pre .k { color: #1f2937; font-weight: 600; }
376 .admin-int-spec-pre .v { color: #047857; }
377 .admin-int-spec-pre .vp { color: #9ca3af; }
378 .admin-int-spec-foot {
379 padding: 10px 16px;
380 border-top: 1px solid #e5e7eb;
381 background: #f9fafb;
382 font-size: 12px;
383 color: #6b7280;
384 }
385 .admin-int-spec-foot code {
386 background: #eef2ff;
387 color: #4338ca;
388 padding: 1px 6px;
389 border-radius: 4px;
390 font-size: 11.5px;
391 }
509c376Claude392`;
393
394interface GroupDef {
395 id: string;
396 title: string;
397 blurb: string;
398}
399
400const GROUPS: Record<string, GroupDef> = {
06a502cClaude401 platform: {
402 id: "platform",
403 title: "Platform",
404 blurb:
405 "Public URL + self-host repo name. APP_BASE_URL must be right or OAuth fails with redirect_uri_mismatch.",
406 },
509c376Claude407 ai: {
408 id: "ai",
409 title: "AI",
410 blurb: "Anthropic — powers PR review, incident response, commit messages.",
411 },
412 email: {
413 id: "email",
414 title: "Email",
415 blurb: "Verification, password reset, and magic-link delivery.",
416 },
417 scm: {
418 id: "scm",
419 title: "Source control",
420 blurb: "GitHub-side API calls (mirror sync, auto-merge sweep).",
421 },
422 security: {
423 id: "security",
424 title: "Security",
425 blurb: "Push-time security scanning via GateTest.",
426 },
427 observability: {
428 id: "observability",
429 title: "Observability",
430 blurb: "Deploy timeline + AI incident responder.",
431 },
432 webhook: {
433 id: "webhook",
434 title: "Outbound webhooks",
435 blurb: "Optional notifications to downstream platforms.",
436 },
437};
438
439async function gate(c: any): Promise<{ user: any } | Response> {
440 const user = c.get("user");
441 if (!user) return c.redirect("/login?next=/admin/integrations");
442 if (!(await isSiteAdmin(user.id))) {
443 return c.html(
444 <Layout title="Forbidden" user={user}>
445 <div class="admin-int-403">
446 <h2>403 — Not a site admin</h2>
447 <p>You don't have permission to view this page.</p>
448 </div>
449 <style dangerouslySetInnerHTML={{ __html: styles }} />
450 </Layout>,
451 403
452 );
453 }
454 return { user };
455}
456
457integrations.get("/admin/integrations", async (c) => {
458 const g = await gate(c);
459 if (g instanceof Response) return g;
460 const { user } = g;
461
462 // Load every field's current value (DB → env → empty). Run in parallel.
463 const values = await Promise.all(
464 INTEGRATION_FIELDS.map(async (f) => ({
465 field: f,
466 value: await getConfigValue(f.key, f.envFallback),
467 }))
468 );
469
470 const groups = new Map<string, typeof values>();
471 for (const v of values) {
472 const arr = groups.get(v.field.group) ?? [];
473 arr.push(v);
474 groups.set(v.field.group, arr);
475 }
476
477 const groupOrder: Array<keyof typeof GROUPS> = [
06a502cClaude478 "platform",
509c376Claude479 "ai",
480 "email",
481 "scm",
482 "security",
483 "observability",
484 "webhook",
485 ];
486
487 const msg = c.req.query("result") || c.req.query("error");
488 const isErr = !!c.req.query("error");
489
490 const totalConfigured = values.filter((v) => v.value.trim().length > 0).length;
491
c5ab657Claude492 // Build the copyable .env spec — same key order as the form, grouped by
493 // section, with placeholders for unset keys. Real secrets are NOT inlined
494 // here (mask them); operators paste this into /etc/gluecron.env and fill
495 // in the blanks. Lines are joined with \n; the inline copy JS reads
496 // textContent so the rendered string is exactly what the operator pastes.
497 const specLines: string[] = [];
498 specLines.push("# Gluecron platform integrations");
499 specLines.push("# Generated from /admin/integrations — paste into /etc/gluecron.env");
500 specLines.push("");
501 for (const gid of groupOrder) {
502 const items = groups.get(gid);
503 if (!items || items.length === 0) continue;
504 const g = GROUPS[gid]!;
505 specLines.push(`# ─── ${g.title} ───`);
506 specLines.push(`# ${g.blurb}`);
507 for (const { field, value } of items) {
508 const v = value.trim();
509 if (field.isSecret) {
510 // Never leak the real secret into the spec — always a placeholder
511 // for paste-and-fill.
512 specLines.push(`${field.key}=${v ? "<unchanged — already set>" : `<paste-${field.key.toLowerCase()}>`}`);
513 } else {
514 specLines.push(`${field.key}=${v || `<set-${field.key.toLowerCase()}>`}`);
515 }
516 }
517 specLines.push("");
518 }
519 const specText = specLines.join("\n").trimEnd();
520
509c376Claude521 return c.html(
f4a1547ccantynz-alt522 <AdminShell active="integrations" title="Integrations" user={user}>
509c376Claude523 <div class="admin-int-wrap">
524 <section class="admin-int-hero">
525 <div class="admin-int-hero-orb" aria-hidden="true" />
526 <div class="admin-int-hero-inner">
527 <div class="admin-int-eyebrow">
528 <span class="pill" aria-hidden="true">
529 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
530 <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" />
531 </svg>
532 </span>
533 Platform integrations · Site admin · <span style="color:var(--accent);font-weight:600">{user.username}</span>
534 </div>
535 <h2 class="admin-int-title">
536 <span class="admin-int-title-grad">Wire it up.</span>
537 </h2>
538 <p class="admin-int-sub">
539 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>.
540 Changes apply immediately — no restart. {totalConfigured} of {INTEGRATION_FIELDS.length} configured.
541 </p>
542 </div>
543 </section>
544
545 {msg && (
546 <div class={"admin-int-banner " + (isErr ? "is-error" : "is-ok")}>
547 {decodeURIComponent(msg)}
548 </div>
549 )}
550
c5ab657Claude551 <section class="admin-int-spec" aria-labelledby="env-spec-title">
552 <header class="admin-int-spec-head">
553 <div>
554 <p class="admin-int-spec-title" id="env-spec-title">
555 <span class="admin-int-spec-title-dot" aria-hidden="true" />
556 .env spec
557 </p>
558 <span class="admin-int-spec-sub">
559 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.
560 </span>
561 </div>
562 <button
563 type="button"
564 class="admin-int-spec-copy"
565 data-spec-copy
566 aria-label="Copy .env spec to clipboard"
567 >
568 <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">
569 <rect x="9" y="9" width="13" height="13" rx="2" ry="2" />
570 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
571 </svg>
572 <span data-spec-copy-label>Copy</span>
573 </button>
574 </header>
575 <pre class="admin-int-spec-pre" data-spec-text>{specText}</pre>
576 <div class="admin-int-spec-foot">
577 Reload after editing the env file: <code>sudo systemctl restart gluecron</code> · or save inline below for no-restart updates.
578 </div>
579 </section>
580
509c376Claude581 <form method="post" action="/admin/integrations">
582 {groupOrder.map((gid) => {
583 const items = groups.get(gid);
584 if (!items || items.length === 0) return null;
585 const g = GROUPS[gid]!;
586 return (
587 <section class="admin-int-section">
588 <header class="admin-int-section-head">
589 <div>
590 <h3 class="admin-int-section-title">{g.title}</h3>
591 <p class="admin-int-section-sub">{g.blurb}</p>
592 </div>
593 </header>
594 <div class="admin-int-section-body">
595 {items.map(({ field, value }) => {
596 const configured = value.trim().length > 0;
597 const display = field.isSecret && configured
598 ? maskSecret(value)
599 : value;
600 return (
601 <div class="admin-int-field">
602 <div class="admin-int-field-row">
603 <label for={`int-${field.key}`}>{field.key}</label>
604 <span
605 class={
606 "admin-int-status " +
607 (configured ? "is-set" : "is-missing")
608 }
609 >
610 <span class="dot" aria-hidden="true" />
611 {configured ? "configured" : "missing"}
612 </span>
613 </div>
614 <input
615 id={`int-${field.key}`}
616 type="text"
617 name={field.key}
618 value={display}
619 aria-label={field.label}
620 placeholder={
621 field.isSecret
622 ? "Paste the secret here"
623 : "Set a value"
624 }
625 class="admin-int-input"
626 autocomplete="off"
627 spellcheck={false}
628 />
629 <div class="admin-int-hint">
630 {field.helper}
631 {field.helperLink && (
632 <>
633 {" "}
634 <a
635 href={field.helperLink.href}
636 target="_blank"
637 rel="noopener noreferrer"
638 >
639 {field.helperLink.text} ↗
640 </a>
641 </>
642 )}
643 {" · env fallback: "}
644 <code>{field.envFallback}</code>
645 </div>
646 </div>
647 );
648 })}
649 </div>
650 </section>
651 );
652 })}
653
654 <div class="admin-int-section" style="margin-bottom:0">
655 <div class="admin-int-foot">
656 <span class="admin-int-foot-hint">
657 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.
658 </span>
659 <button type="submit" class="btn btn-primary">
660 Save all changes
661 </button>
662 </div>
663 </div>
664 </form>
665
666 <div class="admin-int-bottom-actions">
667 Verify your changes turned the warnings green on{" "}
668 <a href="/admin/health">/admin/health</a>.
669 </div>
670 </div>
671 <style dangerouslySetInnerHTML={{ __html: styles }} />
c5ab657Claude672 <script
673 dangerouslySetInnerHTML={{
674 __html: `
675 (function(){
676 var btn = document.querySelector('[data-spec-copy]');
677 var pre = document.querySelector('[data-spec-text]');
678 var label = document.querySelector('[data-spec-copy-label]');
679 if (!btn || !pre || !label) return;
680 btn.addEventListener('click', function(){
681 var text = pre.textContent || '';
682 var done = function(){
683 btn.classList.add('is-copied');
684 label.textContent = 'Copied';
685 setTimeout(function(){
686 btn.classList.remove('is-copied');
687 label.textContent = 'Copy';
688 }, 1800);
689 };
690 if (navigator.clipboard && navigator.clipboard.writeText) {
691 navigator.clipboard.writeText(text).then(done).catch(function(){
692 // Fallback for older browsers / non-secure contexts
693 var ta = document.createElement('textarea');
694 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
695 document.body.appendChild(ta); ta.select();
696 try { document.execCommand('copy'); done(); } catch(e){}
697 document.body.removeChild(ta);
698 });
699 } else {
700 var ta = document.createElement('textarea');
701 ta.value = text; ta.style.position='fixed'; ta.style.left='-9999px';
702 document.body.appendChild(ta); ta.select();
703 try { document.execCommand('copy'); done(); } catch(e){}
704 document.body.removeChild(ta);
705 }
706 });
707 })();
708 `,
709 }}
710 />
f4a1547ccantynz-alt711 </AdminShell>
509c376Claude712 );
713});
714
715integrations.post("/admin/integrations", async (c) => {
716 const g = await gate(c);
717 if (g instanceof Response) return g;
718 const { user } = g;
719
720 const body = await c.req.parseBody();
721
722 let saved = 0;
723 let skipped = 0;
724 const errors: string[] = [];
725
726 for (const field of INTEGRATION_FIELDS) {
727 const submitted = String(body[field.key] ?? "").trim();
728
729 // Don't overwrite a real secret with the mask we showed in the form.
730 if (isMaskedValue(submitted)) {
731 skipped++;
732 continue;
733 }
734
735 // Read the current value to detect a no-op (avoids spurious audit rows).
736 const current = await getConfigValue(field.key, field.envFallback);
737 if (submitted === current) {
738 skipped++;
739 continue;
740 }
741
742 try {
743 await setConfigValue(field.key, submitted, user.id);
744 await audit({
745 userId: user.id,
746 action: "admin.integrations.save",
747 targetType: "system_config",
748 targetId: field.key,
749 // Audit the KEY name + whether a value is now set — NEVER the value.
750 metadata: {
751 key: field.key,
752 hadValue: current.length > 0,
753 hasValue: submitted.length > 0,
754 },
755 });
756 saved++;
757 } catch (err) {
758 const msg = err instanceof Error ? err.message : String(err);
759 errors.push(`${field.key}: ${msg}`);
760 }
761 }
762
763 if (errors.length > 0) {
764 return c.redirect(
765 `/admin/integrations?error=${encodeURIComponent(
766 `Saved ${saved}, but ${errors.length} failed: ${errors.join("; ")}`
767 )}`
768 );
769 }
770
771 const summary =
772 saved === 0
773 ? "No changes — every field matched the current value."
774 : `Saved ${saved} integration${saved === 1 ? "" : "s"}.${skipped > 0 ? ` ${skipped} unchanged.` : ""}`;
775 return c.redirect(`/admin/integrations?result=${encodeURIComponent(summary)}`);
776});
777
778export default integrations;