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

deploy-targets.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.

deploy-targets.tsxBlame769 lines · 2 contributors
9aaa128Claude1/**
2 * Customer-facing deploy targets — /settings/deploy-targets
3 *
4 * Lets any authenticated user manage SSH deploy targets for their own repos.
5 * Private keys are encrypted at rest with AES-256-GCM using SERVER_TARGETS_KEY
6 * (same scheme as the admin surface in admin-server-targets.tsx).
7 *
8 * GET /settings/deploy-targets — list user's own targets
9 * POST /settings/deploy-targets — create a new target
10 * POST /settings/deploy-targets/:id/delete — delete (owner-only)
11 * POST /settings/deploy-targets/:id/test — test SSH connectivity
12 */
13
14import { Hono } from "hono";
15import { and, desc, eq } from "drizzle-orm";
16import { db } from "../db";
17import { serverTargets } from "../db/schema";
18import { Layout } from "../views/layout";
ae2a071ccanty labs19import { SettingsSubnav } from "./settings";
9aaa128Claude20import type { AuthEnv } from "../middleware/auth";
21import { requireAuth } from "../middleware/auth";
22import {
23 createTarget,
24 deleteTarget,
25 recordPin,
26} from "../lib/server-target-store";
27import { testConnection } from "../lib/server-targets";
28import { getMasterKey } from "../lib/server-targets-crypto";
29
30const deployTargets = new Hono<AuthEnv>();
31
32deployTargets.use("/settings/deploy-targets*", requireAuth);
33
34// ─── Scoped styles ────────────────────────────────────────────────────────────
35
36const styles = `
37 .dt-wrap {
38 max-width: 960px;
39 margin: 0 auto;
40 padding: var(--space-6) var(--space-4);
41 }
42
43 /* ─── Hero ─── */
44 .dt-hero {
45 position: relative;
46 margin-bottom: var(--space-6);
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 .dt-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%);
9aaa128Claude59 opacity: 0.7;
60 pointer-events: none;
61 }
62 .dt-hero-orb {
63 position: absolute;
64 inset: -20% -10% auto auto;
65 width: 360px; height: 360px;
6fd5915Claude66 background: radial-gradient(circle, rgba(91,110,232,0.18), rgba(95,143,160,0.09) 45%, transparent 70%);
9aaa128Claude67 filter: blur(80px);
68 opacity: 0.65;
69 pointer-events: none;
70 z-index: 0;
71 }
72 .dt-hero-inner {
73 position: relative;
74 z-index: 1;
75 max-width: 640px;
76 }
77 .dt-hero-eyebrow {
78 font-size: 13px;
79 color: var(--text-muted);
80 margin-bottom: var(--space-2);
81 }
82 .dt-hero-title {
83 font-size: clamp(26px, 3.5vw, 36px);
84 font-family: var(--font-display);
85 font-weight: 800;
86 letter-spacing: -0.028em;
87 line-height: 1.05;
88 margin: 0 0 var(--space-2);
89 color: var(--text-strong);
90 }
91 .dt-hero-title .gradient-text {
6fd5915Claude92 background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%);
9aaa128Claude93 -webkit-background-clip: text;
94 background-clip: text;
95 -webkit-text-fill-color: transparent;
96 color: transparent;
97 }
98 .dt-hero-sub {
99 font-size: 14px;
100 color: var(--text-muted);
101 margin: 0;
102 line-height: 1.5;
103 }
104
105 /* ─── Subnav ─── */
106 .dt-subnav {
107 display: flex;
108 gap: 4px;
109 flex-wrap: wrap;
110 margin-bottom: var(--space-5);
111 padding: 4px;
112 background: var(--bg-elevated);
113 border: 1px solid var(--border);
114 border-radius: 9999px;
115 width: fit-content;
116 max-width: 100%;
117 overflow-x: auto;
118 }
119 .dt-subnav a {
120 display: inline-flex;
121 align-items: center;
122 gap: 6px;
123 padding: 6px 14px;
124 font-size: 13px;
125 font-weight: 500;
126 color: var(--text-muted);
127 border-radius: 9999px;
128 text-decoration: none;
129 white-space: nowrap;
130 transition: all 120ms ease;
131 }
132 .dt-subnav a:hover {
133 color: var(--text-strong);
134 background: var(--bg-hover);
135 }
136 .dt-subnav a.is-active {
137 color: var(--text-strong);
6fd5915Claude138 background: rgba(91,110,232,0.16);
139 box-shadow: inset 0 0 0 1px rgba(91,110,232,0.35);
9aaa128Claude140 }
141
142 /* ─── Banners ─── */
143 .dt-banner {
144 display: flex;
145 align-items: center;
146 gap: 10px;
147 padding: 12px 16px;
148 border-radius: 12px;
149 font-size: 13.5px;
150 margin-bottom: var(--space-4);
151 line-height: 1.5;
152 }
153 .dt-banner-success {
154 background: rgba(52,211,153,0.08);
155 color: #6ee7b7;
156 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.30);
157 }
158 .dt-banner-error {
159 background: rgba(248,113,113,0.08);
160 color: #fca5a5;
161 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.30);
162 }
163 .dt-banner-icon {
164 width: 18px; height: 18px;
165 border-radius: 9999px;
166 flex-shrink: 0;
167 display: inline-flex;
168 align-items: center;
169 justify-content: center;
170 font-size: 12px;
171 font-weight: 700;
172 }
173 .dt-banner-success .dt-banner-icon { background: rgba(52,211,153,0.18); color: #34d399; }
174 .dt-banner-error .dt-banner-icon { background: rgba(248,113,113,0.18); color: #f87171; }
175
176 /* ─── Section cards ─── */
177 .dt-section {
178 background: var(--bg-elevated);
179 border: 1px solid var(--border);
180 border-radius: 14px;
181 margin-bottom: var(--space-5);
182 overflow: hidden;
183 }
184 .dt-section-head {
185 padding: var(--space-4) var(--space-5) var(--space-3);
186 border-bottom: 1px solid var(--border);
187 }
188 .dt-section-eyebrow {
189 font-size: 11px;
190 font-weight: 600;
191 letter-spacing: 0.08em;
192 text-transform: uppercase;
193 color: var(--accent);
194 margin-bottom: 6px;
195 }
196 .dt-section-title {
197 font-family: var(--font-display);
198 font-size: 18px;
199 font-weight: 700;
200 letter-spacing: -0.018em;
201 margin: 0 0 4px;
202 color: var(--text-strong);
203 }
204 .dt-section-desc {
205 font-size: 13.5px;
206 color: var(--text-muted);
207 margin: 0;
208 line-height: 1.5;
209 }
210 .dt-section-body { padding: var(--space-4) var(--space-5); }
211 .dt-section-foot {
212 padding: var(--space-3) var(--space-5);
213 border-top: 1px solid var(--border);
214 background: rgba(255,255,255,0.012);
215 display: flex;
216 justify-content: flex-end;
217 gap: var(--space-2);
218 align-items: center;
219 flex-wrap: wrap;
220 }
221
222 /* ─── Form fields ─── */
223 .dt-field { margin-bottom: var(--space-4); }
224 .dt-field:last-child { margin-bottom: 0; }
225 .dt-field-label {
226 display: block;
227 font-size: 13px;
228 font-weight: 600;
229 color: var(--text-strong);
230 margin-bottom: 6px;
231 letter-spacing: -0.005em;
232 }
233 .dt-field-hint {
234 font-size: 12.5px;
235 color: var(--text-muted);
236 margin-top: 6px;
237 line-height: 1.45;
238 }
239 .dt-input,
240 .dt-textarea {
241 width: 100%;
242 padding: 9px 12px;
243 font-size: 14px;
244 color: var(--text);
245 background: var(--bg);
246 border: 1px solid var(--border-strong);
247 border-radius: 8px;
248 outline: none;
249 transition: border-color 120ms ease, box-shadow 120ms ease;
250 font-family: var(--font-sans);
251 box-sizing: border-box;
252 }
253 .dt-textarea {
254 font-family: var(--font-mono);
255 font-size: 12.5px;
256 line-height: 1.5;
257 resize: vertical;
258 }
259 .dt-input:focus,
260 .dt-textarea:focus {
261 border-color: var(--border-focus);
6fd5915Claude262 box-shadow: 0 0 0 3px rgba(91,110,232,0.18);
9aaa128Claude263 }
264 .dt-row-2 {
265 display: grid;
266 grid-template-columns: 1fr 120px;
267 gap: var(--space-3);
268 }
269 @media (max-width: 560px) {
270 .dt-row-2 { grid-template-columns: 1fr; }
271 }
272
273 /* ─── Target cards ─── */
274 .dt-target-card {
275 display: flex;
276 justify-content: space-between;
277 align-items: flex-start;
278 gap: var(--space-3);
279 padding: 14px 16px;
280 border: 1px solid var(--border);
281 border-radius: 12px;
282 background: var(--bg-secondary);
283 margin-bottom: 10px;
284 transition: border-color 120ms ease, background 120ms ease;
285 }
286 .dt-target-card:last-child { margin-bottom: 0; }
287 .dt-target-card:hover {
288 border-color: var(--border-strong);
289 background: rgba(255,255,255,0.018);
290 }
291 .dt-target-name {
292 font-size: 14px;
293 font-weight: 600;
294 color: var(--text-strong);
295 margin: 0 0 4px;
296 }
297 .dt-target-host {
298 display: inline-block;
299 font-family: var(--font-mono);
300 font-size: 12px;
301 color: var(--text-muted);
302 background: var(--bg-tertiary);
303 padding: 2px 8px;
304 border-radius: 6px;
305 }
306 .dt-target-meta {
307 margin-top: 6px;
308 font-size: 12.5px;
309 color: var(--text-muted);
310 display: flex;
311 gap: 10px;
312 flex-wrap: wrap;
313 align-items: center;
314 }
315 .dt-status-pill {
316 display: inline-flex;
317 align-items: center;
318 gap: 4px;
319 padding: 2px 8px;
320 border-radius: 9999px;
321 font-size: 11px;
322 font-weight: 600;
323 letter-spacing: 0.02em;
324 }
325 .dt-status-verified {
326 background: rgba(52,211,153,0.12);
327 color: #6ee7b7;
328 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.28);
329 }
330 .dt-status-unverified {
331 background: rgba(234,179,8,0.10);
332 color: #fde047;
333 box-shadow: inset 0 0 0 1px rgba(234,179,8,0.24);
334 }
335 .dt-target-actions {
336 display: flex;
337 gap: 6px;
338 flex-shrink: 0;
339 flex-wrap: wrap;
340 align-items: flex-start;
341 }
342 .dt-empty {
343 padding: var(--space-5);
344 text-align: center;
345 border: 1px dashed var(--border);
346 border-radius: 12px;
347 background: var(--bg-secondary);
348 color: var(--text-muted);
349 font-size: 13.5px;
350 }
351
352 /* ─── Warning banner (missing key) ─── */
353 .dt-warn {
354 padding: 12px 16px;
355 border-radius: 10px;
356 background: rgba(234,179,8,0.08);
357 color: #fde047;
358 box-shadow: inset 0 0 0 1px rgba(234,179,8,0.24);
359 font-size: 13.5px;
360 margin-bottom: var(--space-4);
361 display: flex;
362 gap: 10px;
363 align-items: center;
364 }
365
366 @media (max-width: 640px) {
367 .dt-target-card { flex-direction: column; }
368 .dt-target-actions { flex-direction: row; }
369 }
370`;
371
372// ─── Shared subnav (mirrors settings.tsx pattern) ────────────────────────────
373
374function DeployTargetsSubnav() {
375 return (
376 <nav class="dt-subnav" aria-label="Settings sections">
377 <a href="/settings">Profile</a>
378 <a href="/settings/keys">SSH keys</a>
379 <a href="/settings/agents">Agents</a>
380 <a href="/settings/deploy-targets" class="is-active" aria-current="page">
381 Deploy targets
382 </a>
383 </nav>
384 );
385}
386
387function Banner(props: { kind: "success" | "error"; text: string }) {
388 return (
389 <div class={`dt-banner dt-banner-${props.kind}`} role="status">
390 <span class="dt-banner-icon" aria-hidden="true">
391 {props.kind === "success" ? "✓" : "!"}
392 </span>
393 <span>{props.text}</span>
394 </div>
395 );
396}
397
398// ─── GET /settings/deploy-targets ────────────────────────────────────────────
399
400deployTargets.get("/settings/deploy-targets", async (c) => {
401 const user = c.get("user")!;
402 const ok = c.req.query("ok") ?? undefined;
403 const err = c.req.query("err") ?? undefined;
404
405 // Fetch only targets belonging to this user
406 const targets = await db
407 .select()
408 .from(serverTargets)
409 .where(eq(serverTargets.createdBy, user.id))
410 .orderBy(desc(serverTargets.createdAt));
411
412 const keyConfigured = getMasterKey() !== null;
413
414 return c.html(
415 <Layout title="Deploy targets — settings" user={user}>
416 <style dangerouslySetInnerHTML={{ __html: styles }} />
ae2a071ccanty labs417 <SettingsSubnav active="deploy-targets" />
9aaa128Claude418 <div class="dt-wrap">
419 {/* ─── Hero ─── */}
420 <div class="dt-hero">
421 <div class="dt-hero-orb" aria-hidden="true" />
422 <div class="dt-hero-inner">
423 <div class="dt-hero-eyebrow">
424 Your account · <span style="color:var(--accent);font-weight:600">{user.username}</span>
425 </div>
426 <h1 class="dt-hero-title">
427 Deploy <span class="gradient-text">targets</span>.
428 </h1>
429 <p class="dt-hero-sub">
430 SSH boxes that Gluecron can deploy to. Private keys are encrypted
431 at rest and never displayed again after saving.
432 </p>
433 </div>
434 </div>
435
436 <DeployTargetsSubnav />
437
438 {!keyConfigured && (
439 <div class="dt-warn" role="alert">
440 <span aria-hidden="true">⚠</span>
441 <span>
442 <strong>SERVER_TARGETS_KEY not set.</strong> Deploy targets require
443 this environment variable (a 64-character hex string / 32-byte AES key)
444 to encrypt private keys. Contact your administrator.
445 </span>
446 </div>
447 )}
448
449 {ok && <Banner kind="success" text={decodeURIComponent(ok)} />}
450 {err && <Banner kind="error" text={decodeURIComponent(err)} />}
451
452 {/* ─── Existing targets ─── */}
453 <section class="dt-section">
454 <div class="dt-section-head">
455 <div class="dt-section-eyebrow">SSH deploy targets</div>
456 <h2 class="dt-section-title">Your targets</h2>
457 <p class="dt-section-desc">
458 Boxes registered to your account. Each target can be linked to a
459 repo + branch so pushes trigger deploys automatically.
460 </p>
461 </div>
462 <div class="dt-section-body">
463 {targets.length === 0 ? (
464 <div class="dt-empty">
465 No deploy targets yet. Add your first box below.
466 </div>
467 ) : (
468 targets.map((t) => (
469 <div class="dt-target-card">
470 <div>
471 <div class="dt-target-name">{t.name}</div>
472 <code class="dt-target-host">
473 {t.sshUser}@{t.host}:{t.port}
474 </code>
475 <div class="dt-target-meta">
476 <span
477 class={
478 "dt-status-pill " +
479 (t.status === "verified"
480 ? "dt-status-verified"
481 : "dt-status-unverified")
482 }
483 >
484 {t.status}
485 </span>
486 {t.deployPath && (
487 <span style="font-family:var(--font-mono);font-size:12px">
488 {t.deployPath}
489 </span>
490 )}
491 {t.createdAt && (
492 <span>
493 Added {t.createdAt.toLocaleDateString()}
494 </span>
495 )}
496 </div>
497 </div>
498 <div class="dt-target-actions">
499 <form
500 method="post"
501 action={`/settings/deploy-targets/${t.id}/test`}
502 style="display:inline"
503 >
504 <button
505 type="submit"
506 class="btn btn-sm"
507 title="Test SSH connection"
508 >
509 Test
510 </button>
511 </form>
512 <form
513 method="post"
514 action={`/settings/deploy-targets/${t.id}/delete`}
515 style="display:inline"
516 onsubmit="return confirm('Delete this deploy target?')"
517 >
518 <button type="submit" class="btn btn-sm btn-danger">
519 Delete
520 </button>
521 </form>
522 </div>
523 </div>
524 ))
525 )}
526 </div>
527 </section>
528
529 {/* ─── Add new target form ─── */}
530 <section class="dt-section">
531 <div class="dt-section-head">
532 <div class="dt-section-eyebrow">Add target</div>
533 <h2 class="dt-section-title">New deploy target</h2>
534 <p class="dt-section-desc">
535 Enter your server's SSH credentials. The private key is encrypted
536 immediately and never stored in plaintext.
537 </p>
538 </div>
539 <form method="post" action="/settings/deploy-targets">
540 <div class="dt-section-body">
541 <div class="dt-field">
542 <label class="dt-field-label" for="dt-name">
543 Name
544 </label>
545 <input
546 class="dt-input"
547 id="dt-name"
548 name="name"
549 required
550 pattern="[a-z0-9-]+"
551 placeholder="my-prod-server"
552 autocomplete="off"
553 />
554 <div class="dt-field-hint">
555 Lowercase letters, numbers and hyphens only. Must be unique
556 across all targets on the platform.
557 </div>
558 </div>
559
560 <div class="dt-row-2">
561 <div class="dt-field">
562 <label class="dt-field-label" for="dt-host">Host</label>
563 <input
564 class="dt-input"
565 id="dt-host"
566 name="host"
567 required
568 placeholder="1.2.3.4 or example.com"
569 autocomplete="off"
570 />
571 </div>
572 <div class="dt-field">
573 <label class="dt-field-label" for="dt-port">Port</label>
574 <input
575 class="dt-input"
576 id="dt-port"
577 name="port"
578 type="number"
579 value="22"
580 min="1"
581 max="65535"
582 />
583 </div>
584 </div>
585
586 <div class="dt-field">
587 <label class="dt-field-label" for="dt-ssh-user">
588 SSH username
589 </label>
590 <input
591 class="dt-input"
592 id="dt-ssh-user"
593 name="ssh_user"
594 required
595 placeholder="deploy"
596 autocomplete="off"
597 />
598 </div>
599
600 <div class="dt-field">
601 <label class="dt-field-label" for="dt-private-key">
602 SSH private key
603 </label>
604 <textarea
605 class="dt-textarea"
606 id="dt-private-key"
607 name="private_key"
608 required
609 rows={8}
610 placeholder="-----BEGIN OPENSSH PRIVATE KEY-----&#10;...&#10;-----END OPENSSH PRIVATE KEY-----"
611 />
612 <div class="dt-field-hint">
613 Paste your OpenSSH PEM private key. It is encrypted with
614 AES-256-GCM before being stored and will never be displayed
615 again after saving.
616 </div>
617 </div>
618
619 <div class="dt-field">
620 <label class="dt-field-label" for="dt-deploy-path">
621 Deploy path
622 </label>
623 <input
624 class="dt-input"
625 id="dt-deploy-path"
626 name="deploy_path"
627 placeholder="/var/www/app"
628 value="/var/www/app"
629 />
630 <div class="dt-field-hint">
631 Absolute path on the remote server where your app lives.
632 </div>
633 </div>
634 </div>
635 <div class="dt-section-foot">
636 <button
637 type="submit"
638 class="btn btn-primary"
639 disabled={!keyConfigured}
640 >
641 Add deploy target
642 </button>
643 </div>
644 </form>
645 </section>
646 </div>
647 </Layout>
648 );
649});
650
651// ─── POST /settings/deploy-targets ───────────────────────────────────────────
652
653deployTargets.post("/settings/deploy-targets", async (c) => {
654 const user = c.get("user")!;
655
656 if (getMasterKey() === null) {
657 return c.redirect(
658 "/settings/deploy-targets?err=SERVER_TARGETS_KEY+not+configured"
659 );
660 }
661
662 const form = await c.req.parseBody();
663 const name = String(form.name || "").trim();
664 const host = String(form.host || "").trim();
665 const port = Number(form.port || 22);
666 const sshUser = String(form.ssh_user || "").trim();
667 const privateKey = String(form.private_key || "");
668 const deployPath = String(form.deploy_path || "/var/www/app").trim();
669
670 if (!name || !host || !sshUser || !privateKey) {
671 return c.redirect(
672 "/settings/deploy-targets?err=Name%2C+host%2C+SSH+user+and+private+key+are+required"
673 );
674 }
675 if (!/^[a-z0-9-]+$/.test(name)) {
676 return c.redirect(
677 "/settings/deploy-targets?err=Name+must+be+lowercase+letters%2C+numbers+and+hyphens+only"
678 );
679 }
680 if (!privateKey.includes("PRIVATE KEY")) {
681 return c.redirect(
682 "/settings/deploy-targets?err=Private+key+does+not+look+like+a+valid+OpenSSH+PEM+key"
683 );
684 }
685
686 const out = await createTarget({
687 name,
688 host,
689 port: isNaN(port) ? 22 : port,
690 sshUser,
691 privateKey,
692 deployPath: deployPath || "/var/www/app",
693 deployScript: "bash deploy.sh",
694 watchedRepositoryId: null,
695 watchedBranch: null,
696 createdBy: user.id,
697 });
698
699 if (!out.ok) {
700 return c.redirect(
701 `/settings/deploy-targets?err=${encodeURIComponent(out.error)}`
702 );
703 }
704
705 return c.redirect(
706 `/settings/deploy-targets?ok=Deploy+target+%22${encodeURIComponent(name)}%22+created`
707 );
708});
709
710// ─── POST /settings/deploy-targets/:id/delete ────────────────────────────────
711
712deployTargets.post("/settings/deploy-targets/:id/delete", async (c) => {
713 const user = c.get("user")!;
714 const id = c.req.param("id");
715
716 // Verify ownership before deleting
717 const [target] = await db
718 .select()
719 .from(serverTargets)
720 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
721 .limit(1);
722
723 if (!target) {
724 return c.redirect(
725 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
726 );
727 }
728
729 await deleteTarget(id, user.id);
730 return c.redirect(
731 `/settings/deploy-targets?ok=Target+%22${encodeURIComponent(target.name)}%22+deleted`
732 );
733});
734
735// ─── POST /settings/deploy-targets/:id/test ──────────────────────────────────
736
737deployTargets.post("/settings/deploy-targets/:id/test", async (c) => {
738 const user = c.get("user")!;
739 const id = c.req.param("id");
740
741 // Verify ownership
742 const [target] = await db
743 .select()
744 .from(serverTargets)
745 .where(and(eq(serverTargets.id, id), eq(serverTargets.createdBy, user.id)))
746 .limit(1);
747
748 if (!target) {
749 return c.redirect(
750 "/settings/deploy-targets?err=Target+not+found+or+not+owned+by+you"
751 );
752 }
753
754 const result = await testConnection(target);
755 if (result.ok) {
756 await recordPin(id, result.fingerprint, user.id);
757 return c.redirect(
758 `/settings/deploy-targets?ok=Connection+to+%22${encodeURIComponent(target.name)}%22+verified`
759 );
760 }
761
762 return c.redirect(
763 `/settings/deploy-targets?err=${encodeURIComponent(
764 `${result.stage}: ${result.error}`
765 )}`
766 );
767});
768
769export default deployTargets;