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.tsxBlame572 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; }
291`;
292
293interface GroupDef {
294 id: string;
295 title: string;
296 blurb: string;
297}
298
299const GROUPS: Record<string, GroupDef> = {
300 ai: {
301 id: "ai",
302 title: "AI",
303 blurb: "Anthropic — powers PR review, incident response, commit messages.",
304 },
305 email: {
306 id: "email",
307 title: "Email",
308 blurb: "Verification, password reset, and magic-link delivery.",
309 },
310 scm: {
311 id: "scm",
312 title: "Source control",
313 blurb: "GitHub-side API calls (mirror sync, auto-merge sweep).",
314 },
315 security: {
316 id: "security",
317 title: "Security",
318 blurb: "Push-time security scanning via GateTest.",
319 },
320 observability: {
321 id: "observability",
322 title: "Observability",
323 blurb: "Deploy timeline + AI incident responder.",
324 },
325 webhook: {
326 id: "webhook",
327 title: "Outbound webhooks",
328 blurb: "Optional notifications to downstream platforms.",
329 },
330};
331
332async function gate(c: any): Promise<{ user: any } | Response> {
333 const user = c.get("user");
334 if (!user) return c.redirect("/login?next=/admin/integrations");
335 if (!(await isSiteAdmin(user.id))) {
336 return c.html(
337 <Layout title="Forbidden" user={user}>
338 <div class="admin-int-403">
339 <h2>403 — Not a site admin</h2>
340 <p>You don't have permission to view this page.</p>
341 </div>
342 <style dangerouslySetInnerHTML={{ __html: styles }} />
343 </Layout>,
344 403
345 );
346 }
347 return { user };
348}
349
350integrations.get("/admin/integrations", async (c) => {
351 const g = await gate(c);
352 if (g instanceof Response) return g;
353 const { user } = g;
354
355 // Load every field's current value (DB → env → empty). Run in parallel.
356 const values = await Promise.all(
357 INTEGRATION_FIELDS.map(async (f) => ({
358 field: f,
359 value: await getConfigValue(f.key, f.envFallback),
360 }))
361 );
362
363 const groups = new Map<string, typeof values>();
364 for (const v of values) {
365 const arr = groups.get(v.field.group) ?? [];
366 arr.push(v);
367 groups.set(v.field.group, arr);
368 }
369
370 const groupOrder: Array<keyof typeof GROUPS> = [
371 "ai",
372 "email",
373 "scm",
374 "security",
375 "observability",
376 "webhook",
377 ];
378
379 const msg = c.req.query("result") || c.req.query("error");
380 const isErr = !!c.req.query("error");
381
382 const totalConfigured = values.filter((v) => v.value.trim().length > 0).length;
383
384 return c.html(
385 <Layout title="Integrations — admin" user={user}>
386 <div class="admin-int-wrap">
387 <section class="admin-int-hero">
388 <div class="admin-int-hero-orb" aria-hidden="true" />
389 <div class="admin-int-hero-inner">
390 <div class="admin-int-eyebrow">
391 <span class="pill" aria-hidden="true">
392 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
393 <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" />
394 </svg>
395 </span>
396 Platform integrations · Site admin · <span style="color:var(--accent);font-weight:600">{user.username}</span>
397 </div>
398 <h2 class="admin-int-title">
399 <span class="admin-int-title-grad">Wire it up.</span>
400 </h2>
401 <p class="admin-int-sub">
402 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>.
403 Changes apply immediately — no restart. {totalConfigured} of {INTEGRATION_FIELDS.length} configured.
404 </p>
405 </div>
406 </section>
407
408 {msg && (
409 <div class={"admin-int-banner " + (isErr ? "is-error" : "is-ok")}>
410 {decodeURIComponent(msg)}
411 </div>
412 )}
413
414 <form method="post" action="/admin/integrations">
415 {groupOrder.map((gid) => {
416 const items = groups.get(gid);
417 if (!items || items.length === 0) return null;
418 const g = GROUPS[gid]!;
419 return (
420 <section class="admin-int-section">
421 <header class="admin-int-section-head">
422 <div>
423 <h3 class="admin-int-section-title">{g.title}</h3>
424 <p class="admin-int-section-sub">{g.blurb}</p>
425 </div>
426 </header>
427 <div class="admin-int-section-body">
428 {items.map(({ field, value }) => {
429 const configured = value.trim().length > 0;
430 const display = field.isSecret && configured
431 ? maskSecret(value)
432 : value;
433 return (
434 <div class="admin-int-field">
435 <div class="admin-int-field-row">
436 <label for={`int-${field.key}`}>{field.key}</label>
437 <span
438 class={
439 "admin-int-status " +
440 (configured ? "is-set" : "is-missing")
441 }
442 >
443 <span class="dot" aria-hidden="true" />
444 {configured ? "configured" : "missing"}
445 </span>
446 </div>
447 <input
448 id={`int-${field.key}`}
449 type="text"
450 name={field.key}
451 value={display}
452 aria-label={field.label}
453 placeholder={
454 field.isSecret
455 ? "Paste the secret here"
456 : "Set a value"
457 }
458 class="admin-int-input"
459 autocomplete="off"
460 spellcheck={false}
461 />
462 <div class="admin-int-hint">
463 {field.helper}
464 {field.helperLink && (
465 <>
466 {" "}
467 <a
468 href={field.helperLink.href}
469 target="_blank"
470 rel="noopener noreferrer"
471 >
472 {field.helperLink.text} ↗
473 </a>
474 </>
475 )}
476 {" · env fallback: "}
477 <code>{field.envFallback}</code>
478 </div>
479 </div>
480 );
481 })}
482 </div>
483 </section>
484 );
485 })}
486
487 <div class="admin-int-section" style="margin-bottom:0">
488 <div class="admin-int-foot">
489 <span class="admin-int-foot-hint">
490 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.
491 </span>
492 <button type="submit" class="btn btn-primary">
493 Save all changes
494 </button>
495 </div>
496 </div>
497 </form>
498
499 <div class="admin-int-bottom-actions">
500 Verify your changes turned the warnings green on{" "}
501 <a href="/admin/health">/admin/health</a>.
502 </div>
503 </div>
504 <style dangerouslySetInnerHTML={{ __html: styles }} />
505 </Layout>
506 );
507});
508
509integrations.post("/admin/integrations", async (c) => {
510 const g = await gate(c);
511 if (g instanceof Response) return g;
512 const { user } = g;
513
514 const body = await c.req.parseBody();
515
516 let saved = 0;
517 let skipped = 0;
518 const errors: string[] = [];
519
520 for (const field of INTEGRATION_FIELDS) {
521 const submitted = String(body[field.key] ?? "").trim();
522
523 // Don't overwrite a real secret with the mask we showed in the form.
524 if (isMaskedValue(submitted)) {
525 skipped++;
526 continue;
527 }
528
529 // Read the current value to detect a no-op (avoids spurious audit rows).
530 const current = await getConfigValue(field.key, field.envFallback);
531 if (submitted === current) {
532 skipped++;
533 continue;
534 }
535
536 try {
537 await setConfigValue(field.key, submitted, user.id);
538 await audit({
539 userId: user.id,
540 action: "admin.integrations.save",
541 targetType: "system_config",
542 targetId: field.key,
543 // Audit the KEY name + whether a value is now set — NEVER the value.
544 metadata: {
545 key: field.key,
546 hadValue: current.length > 0,
547 hasValue: submitted.length > 0,
548 },
549 });
550 saved++;
551 } catch (err) {
552 const msg = err instanceof Error ? err.message : String(err);
553 errors.push(`${field.key}: ${msg}`);
554 }
555 }
556
557 if (errors.length > 0) {
558 return c.redirect(
559 `/admin/integrations?error=${encodeURIComponent(
560 `Saved ${saved}, but ${errors.length} failed: ${errors.join("; ")}`
561 )}`
562 );
563 }
564
565 const summary =
566 saved === 0
567 ? "No changes — every field matched the current value."
568 : `Saved ${saved} integration${saved === 1 ? "" : "s"}.${skipped > 0 ? ` ${skipped} unchanged.` : ""}`;
569 return c.redirect(`/admin/integrations?result=${encodeURIComponent(summary)}`);
570});
571
572export default integrations;