Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
Commit54eab24unknown_key

polish: 2026 hero treatment for import-secrets + import-bulk + legal + about

Claude committed on May 25, 2026Parent: ce3b805
5 files changed+262556754eab24438ff56fbf330202765bfc9a52fecb72a
5 changed files+2625−567
Modifiedsrc/routes/import-secrets.tsx+431−120View fileUnifiedSplit
3535import { softAuth, requireAuth } from "../middleware/auth";
3636import type { AuthEnv } from "../middleware/auth";
3737import { requireRepoAccess } from "../middleware/repo-access";
38import { Alert, Container, Text } from "../views/ui";
3938import { upsertRepoSecret, listRepoSecrets } from "../lib/workflow-secrets";
4039import { decryptSecret } from "../lib/workflow-secrets-crypto";
4140import { audit } from "../lib/notify";
4746const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
4847const MAX_VALUE_LEN = 32768;
4948
49// ─── Scoped CSS ─────────────────────────────────────────────────────────────
50// Every class prefixed `.is-checklist-` so this surface cannot bleed into
51// neighbouring repo pages. Mirrors the 2026 hero + numbered-step pattern
52// from `src/routes/connect-claude.tsx` and `src/routes/admin-integrations.tsx`.
53const checklistStyles = `
54 .is-checklist-wrap {
55 max-width: 820px;
56 margin: 0 auto;
57 padding: var(--space-6) var(--space-4);
58 }
59
60 /* ─── Hero ─── */
61 .is-checklist-hero {
62 position: relative;
63 margin-bottom: var(--space-5);
64 padding: var(--space-5) var(--space-6);
65 background: var(--bg-elevated);
66 border: 1px solid var(--border);
67 border-radius: 16px;
68 overflow: hidden;
69 }
70 .is-checklist-hero::before {
71 content: '';
72 position: absolute;
73 top: 0; left: 0; right: 0;
74 height: 2px;
75 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
76 opacity: 0.75;
77 pointer-events: none;
78 }
79 .is-checklist-hero-orb {
80 position: absolute;
81 inset: -22% -10% auto auto;
82 width: 380px; height: 380px;
83 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
84 filter: blur(80px);
85 opacity: 0.7;
86 pointer-events: none;
87 z-index: 0;
88 }
89 .is-checklist-hero-inner { position: relative; z-index: 1; }
90 .is-checklist-eyebrow {
91 display: inline-flex;
92 align-items: center;
93 gap: 8px;
94 font-family: var(--font-mono);
95 font-size: 11.5px;
96 text-transform: uppercase;
97 letter-spacing: 0.14em;
98 color: var(--text-muted);
99 font-weight: 600;
100 margin-bottom: 14px;
101 }
102 .is-checklist-eyebrow-pill {
103 display: inline-flex;
104 align-items: center;
105 justify-content: center;
106 width: 18px; height: 18px;
107 border-radius: 6px;
108 background: rgba(140,109,255,0.14);
109 color: #b69dff;
110 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
111 }
112 .is-checklist-title {
113 font-size: clamp(26px, 3.6vw, 36px);
114 font-family: var(--font-display);
115 font-weight: 800;
116 letter-spacing: -0.028em;
117 line-height: 1.05;
118 margin: 0 0 var(--space-2);
119 color: var(--text-strong);
120 }
121 .is-checklist-title-grad {
122 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
123 -webkit-background-clip: text;
124 background-clip: text;
125 -webkit-text-fill-color: transparent;
126 color: transparent;
127 }
128 .is-checklist-sub {
129 font-size: 15px;
130 color: var(--text-muted);
131 margin: 0;
132 line-height: 1.55;
133 }
134 .is-checklist-sub code {
135 font-family: var(--font-mono);
136 font-size: 12.5px;
137 background: var(--bg-tertiary);
138 padding: 1px 6px;
139 border-radius: 4px;
140 color: var(--text);
141 }
142 .is-checklist-counts {
143 display: inline-flex;
144 align-items: center;
145 gap: 10px;
146 margin-top: var(--space-3);
147 padding: 6px 12px;
148 background: var(--bg-secondary);
149 border: 1px solid var(--border-subtle);
150 border-radius: 9999px;
151 font-size: 12.5px;
152 color: var(--text);
153 font-family: var(--font-mono);
154 }
155 .is-checklist-counts .num { color: var(--accent); font-weight: 700; }
156 .is-checklist-counts .sep { color: var(--text-faint); }
157
158 /* ─── Banners ─── */
159 .is-checklist-banner {
160 margin-bottom: var(--space-4);
161 padding: 10px 14px;
162 border-radius: 10px;
163 font-size: 13.5px;
164 border: 1px solid var(--border);
165 background: rgba(255,255,255,0.025);
166 color: var(--text);
167 }
168 .is-checklist-banner.is-ok {
169 border-color: rgba(52,211,153,0.40);
170 background: rgba(52,211,153,0.08);
171 color: #bbf7d0;
172 }
173 .is-checklist-banner.is-error {
174 border-color: rgba(248,113,113,0.40);
175 background: rgba(248,113,113,0.08);
176 color: #fecaca;
177 }
178 .is-checklist-banner code {
179 font-family: var(--font-mono);
180 background: rgba(0,0,0,0.18);
181 padding: 1px 6px;
182 border-radius: 4px;
183 }
184
185 /* ─── Numbered checklist cards ─── */
186 .is-checklist-list {
187 display: flex;
188 flex-direction: column;
189 gap: var(--space-3);
190 margin-top: var(--space-4);
191 }
192 .is-checklist-card {
193 position: relative;
194 padding: var(--space-4) var(--space-5);
195 background: var(--bg-elevated);
196 border: 1px solid var(--border);
197 border-radius: 14px;
198 transition: border-color 150ms ease, transform 150ms ease;
199 }
200 .is-checklist-card:hover { border-color: var(--border-strong); }
201 .is-checklist-card.is-pasted { border-color: rgba(52,211,153,0.30); }
202
203 .is-checklist-card-head {
204 display: flex;
205 align-items: center;
206 gap: 12px;
207 margin-bottom: 10px;
208 }
209 .is-checklist-num {
210 display: inline-flex;
211 align-items: center;
212 justify-content: center;
213 width: 26px; height: 26px;
214 border-radius: 50%;
215 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
216 color: #c5b3ff;
217 border: 1px solid rgba(140,109,255,0.40);
218 font-family: var(--font-display);
219 font-weight: 700;
220 font-size: 13px;
221 flex-shrink: 0;
222 }
223 .is-checklist-card-name {
224 font-family: var(--font-mono);
225 font-size: 14px;
226 color: var(--text-strong);
227 font-weight: 600;
228 word-break: break-all;
229 flex: 1;
230 }
231
232 .is-checklist-pill {
233 display: inline-flex;
234 align-items: center;
235 gap: 6px;
236 padding: 2px 9px;
237 border-radius: 9999px;
238 font-size: 11px;
239 font-weight: 600;
240 letter-spacing: 0.04em;
241 text-transform: uppercase;
242 flex-shrink: 0;
243 }
244 .is-checklist-pill .dot {
245 width: 6px; height: 6px;
246 border-radius: 50%;
247 background: currentColor;
248 }
249 .is-checklist-pill.is-saved {
250 color: #6ee7b7;
251 background: rgba(52,211,153,0.14);
252 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
253 }
254 .is-checklist-pill.is-empty {
255 color: #fde68a;
256 background: rgba(251,191,36,0.10);
257 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.30);
258 }
259
260 .is-checklist-form {
261 display: flex;
262 gap: 8px;
263 align-items: stretch;
264 flex-wrap: wrap;
265 }
266 .is-checklist-input {
267 flex: 1;
268 min-width: 220px;
269 padding: 9px 12px;
270 font-size: 13.5px;
271 color: var(--text);
272 background: var(--bg);
273 border: 1px solid var(--border-strong);
274 border-radius: 8px;
275 outline: none;
276 font-family: var(--font-mono);
277 transition: border-color 120ms ease, box-shadow 120ms ease;
278 box-sizing: border-box;
279 }
280 .is-checklist-input:focus {
281 border-color: var(--border-focus);
282 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
283 }
284 .is-checklist-save {
285 appearance: none;
286 border: 1px solid var(--border-strong);
287 background: var(--bg-secondary);
288 color: var(--text);
289 padding: 9px 16px;
290 border-radius: 8px;
291 font-family: inherit;
292 font-size: 13.5px;
293 font-weight: 600;
294 cursor: pointer;
295 transition: border-color 150ms ease, background 150ms ease;
296 }
297 .is-checklist-save:hover {
298 border-color: var(--border-focus);
299 background: rgba(255,255,255,0.03);
300 }
301
302 /* ─── Done bar ─── */
303 .is-checklist-done {
304 margin-top: var(--space-6);
305 padding: var(--space-4) var(--space-5);
306 background: var(--bg-elevated);
307 border: 1px solid var(--border);
308 border-radius: 14px;
309 display: flex;
310 align-items: center;
311 gap: var(--space-4);
312 flex-wrap: wrap;
313 }
314 .is-checklist-done-text {
315 flex: 1;
316 min-width: 220px;
317 font-size: 13.5px;
318 color: var(--text-muted);
319 line-height: 1.5;
320 }
321 .is-checklist-done-text strong {
322 color: var(--text-strong);
323 font-weight: 600;
324 }
325 .is-checklist-done-actions {
326 display: flex;
327 align-items: center;
328 gap: 12px;
329 flex-wrap: wrap;
330 }
331 .is-checklist-done-cleanup {
332 display: inline-flex;
333 align-items: center;
334 gap: 8px;
335 font-size: 12.5px;
336 color: var(--text-muted);
337 cursor: pointer;
338 user-select: none;
339 }
340 .is-checklist-done-cleanup input[type="checkbox"] {
341 width: 15px; height: 15px;
342 accent-color: var(--accent);
343 cursor: pointer;
344 }
345 .is-checklist-done-btn {
346 appearance: none;
347 padding: 11px 20px;
348 border-radius: 10px;
349 font-family: inherit;
350 font-size: 14px;
351 font-weight: 700;
352 letter-spacing: -0.005em;
353 cursor: pointer;
354 color: #fff;
355 border: 1px solid rgba(140,109,255,0.55);
356 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
357 box-shadow: 0 4px 14px rgba(140,109,255,0.32);
358 transition: transform 150ms ease, box-shadow 150ms ease, filter 150ms ease;
359 }
360 .is-checklist-done-btn:hover {
361 transform: translateY(-1px);
362 box-shadow: 0 6px 18px rgba(140,109,255,0.42);
363 filter: brightness(1.06);
364 }
365 .is-checklist-done-btn:active { transform: translateY(0); }
366`;
367
50368type ChecklistRow = {
51369 id: string;
52370 name: string;
130448 >
131449 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
132450 <RepoNav owner={ownerName} repo={repoName} active="settings" />
133 <Container maxWidth={780}>
134 <div
135 style="position: sticky; top: 0; background: var(--bg); padding: 20px 0 16px; border-bottom: 1px solid var(--border); margin-bottom: 20px; z-index: 5"
136 >
137 <h2 style="margin-bottom: 6px">
138 Migrate {rows.length} secret{rows.length === 1 ? "" : "s"} from GitHub
139 </h2>
140 <Text size={14} muted style="display:block;margin-bottom:8px">
141 We found {rows.length} Actions secret{rows.length === 1 ? "" : "s"} on
142 your GitHub repo. Paste each value below to migrate them.{" "}
143 <strong>
144 {pastedCount} pasted · {emptyCount} still empty
145 </strong>
146 .
147 </Text>
148 <Text size={13} muted style="display:block">
149 Need the value? Find it in{" "}
150 <code style="font-family: var(--font-mono); background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px">
151 github.com/{ownerName}/{repoName}/settings/secrets/actions
152 </code>
153 {" "}— each value is opaque so you can't read it from GitHub, you'll
154 need to copy from wherever you originally created it
155 (1Password, .env file, etc.).
156 </Text>
157 </div>
451 <style dangerouslySetInnerHTML={{ __html: checklistStyles }} />
452 <div class="is-checklist-wrap">
453 {/* ─── Hero ─── */}
454 <section class="is-checklist-hero">
455 <div class="is-checklist-hero-orb" aria-hidden="true" />
456 <div class="is-checklist-hero-inner">
457 <div class="is-checklist-eyebrow">
458 <span class="is-checklist-eyebrow-pill" aria-hidden="true">
459 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
460 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
461 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
462 </svg>
463 </span>
464 Post-import · {ownerName}/{repoName}
465 </div>
466 <h1 class="is-checklist-title">
467 Migrate{" "}
468 <span class="is-checklist-title-grad">
469 {rows.length} secret{rows.length === 1 ? "" : "s"}
470 </span>{" "}
471 from GitHub.
472 </h1>
473 <p class="is-checklist-sub">
474 We found {rows.length} Actions secret{rows.length === 1 ? "" : "s"} on your
475 GitHub repo. Paste each value below to migrate. GitHub stores values
476 opaquely — copy from wherever you originally created them
477 (1Password, .env file, etc.).
478 </p>
479 <div class="is-checklist-counts" aria-label="Migration progress">
480 <span><span class="num">{pastedCount}</span> pasted</span>
481 <span class="sep">·</span>
482 <span><span class="num">{emptyCount}</span> still empty</span>
483 </div>
484 </div>
485 </section>
158486
159487 {saved && (
160 <Alert variant="success">
488 <div class="is-checklist-banner is-ok" role="status">
161489 Saved <code>{decodeURIComponent(saved)}</code>.
162 </Alert>
490 </div>
491 )}
492 {error && (
493 <div class="is-checklist-banner is-error" role="alert">
494 {decodeURIComponent(error)}
495 </div>
163496 )}
164 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
165497
166 <div
167 class="panel"
168 style="padding: 0; overflow: hidden; margin-top: 8px"
169 >
170 <table
171 class="file-table"
172 style="width: 100%; border-collapse: collapse"
173 >
174 <thead>
175 <tr style="background: var(--bg-secondary); text-align: left">
176 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); width: 40%">
177 Secret name
178 </th>
179 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); width: 15%">
180 Status
181 </th>
182 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
183 Value
184 </th>
185 </tr>
186 </thead>
187 <tbody>
188 {rows.map((row) => {
189 const action = `/${ownerName}/${repoName}/import/secrets/${encodeURIComponent(row.name)}`;
190 const pillStyle =
191 row.status === "pasted"
192 ? "background: var(--green-dim, #1f3d2a); color: var(--green, #3fb950); border: 1px solid var(--green, #3fb950)"
193 : "background: var(--yellow-dim, #3d3520); color: var(--yellow, #d29922); border: 1px solid var(--yellow, #d29922)";
194 return (
195 <tr
196 data-secret-row={row.name}
197 style="border-top: 1px solid var(--border); vertical-align: middle"
498 {/* ─── Numbered checklist cards ─── */}
499 <div class="is-checklist-list">
500 {rows.map((row, idx) => {
501 const action = `/${ownerName}/${repoName}/import/secrets/${encodeURIComponent(row.name)}`;
502 const pasted = row.status === "pasted";
503 return (
504 <div
505 class={"is-checklist-card " + (pasted ? "is-pasted" : "is-empty")}
506 data-secret-row={row.name}
507 >
508 <div class="is-checklist-card-head">
509 <span class="is-checklist-num" aria-hidden="true">
510 {idx + 1}
511 </span>
512 <span class="is-checklist-card-name">{row.name}</span>
513 <span
514 data-status-pill
515 class={"is-checklist-pill " + (pasted ? "is-saved" : "is-empty")}
198516 >
199 <td style="padding: 10px 14px">
200 <code style="font-family: var(--font-mono); font-size: 13px">
201 {row.name}
202 </code>
203 </td>
204 <td style="padding: 10px 14px">
205 <span
206 data-status-pill
207 style={
208 "display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; " +
209 pillStyle
210 }
211 >
212 {row.status === "pasted" ? "Pasted" : "Empty"}
213 </span>
214 </td>
215 <td style="padding: 10px 14px">
216 <form method="post" action={action} style="display: flex; gap: 8px">
217 <input
218 type="password"
219 name="value"
220 required
221 maxlength={MAX_VALUE_LEN}
222 placeholder={
223 row.status === "pasted"
224 ? "Already savedpaste new value to overwrite"
225 : "Paste value"
226 }
227 autocomplete="off"
228 spellcheck={false}
229 style="flex: 1; font-family: var(--font-mono); font-size: 13px; padding: 6px 10px; background: var(--bg); border: 1px solid var(--border); border-radius: var(--radius); color: var(--text)"
230 />
231 <button
232 type="submit"
233 class="btn btn-primary"
234 style="padding: 6px 14px"
235 >
236 Save
237 </button>
238 </form>
239 </td>
240 </tr>
241 );
242 })}
243 </tbody>
244 </table>
517 <span class="dot" aria-hidden="true" />
518 {pasted ? "Saved" : "Empty"}
519 </span>
520 </div>
521 <form method="post" action={action} class="is-checklist-form">
522 <input
523 type="password"
524 name="value"
525 required
526 maxlength={MAX_VALUE_LEN}
527 placeholder={
528 pasted
529 ? "Already savedpaste new value to overwrite"
530 : "Paste value"
531 }
532 autocomplete="off"
533 spellcheck={false}
534 class="is-checklist-input"
535 aria-label={`Value for ${row.name}`}
536 />
537 <button type="submit" class="is-checklist-save">
538 Save
539 </button>
540 </form>
541 </div>
542 );
543 })}
245544 </div>
246545
546 {/* ─── Done bar ─── */}
247547 <form
248548 method="post"
249549 action={`/${ownerName}/${repoName}/import/secrets/done`}
250 style="margin-top: 24px; display: flex; gap: 12px; align-items: center"
550 class="is-checklist-done"
251551 >
252 <button type="submit" class="btn btn-primary">
253 Done — take me to my repo
254 </button>
255 <label style="font-size: 13px; color: var(--text-muted); display: flex; align-items: center; gap: 6px">
256 <input
257 type="checkbox"
258 name="cleanup_empty"
259 value="1"
260 />
261 Also delete the {emptyCount} empty placeholder{emptyCount === 1 ? "" : "s"} on my way out
262 </label>
552 <div class="is-checklist-done-text">
553 {emptyCount > 0 ? (
554 <>
555 <strong>{emptyCount}</strong> placeholder{emptyCount === 1 ? "" : "s"} still empty —
556 finish later via{" "}
557 <code>/{ownerName}/{repoName}/settings/secrets</code>.
558 </>
559 ) : (
560 <>All secrets migrated. <strong>Ship it.</strong></>
561 )}
562 </div>
563 <div class="is-checklist-done-actions">
564 {emptyCount > 0 && (
565 <label class="is-checklist-done-cleanup">
566 <input type="checkbox" name="cleanup_empty" value="1" />
567 Delete the {emptyCount} empty placeholder{emptyCount === 1 ? "" : "s"}
568 </label>
569 )}
570 <button type="submit" class="is-checklist-done-btn">
571 Done — go to repo
572 </button>
573 </div>
263574 </form>
264 </Container>
575 </div>
265576 </Layout>
266577 );
267578 }
Modifiedsrc/routes/legal.tsx+323−8View fileUnifiedSplit
11/**
2 * Legal pages — Terms, Privacy, AUP served from the website.
2 * Legal pages — Terms, Privacy, AUP served from markdown source.
3 *
4 * The body wraps the rendered markdown in a 2026-style hero card +
5 * table of contents + scoped section card. The TOC is parsed from the
6 * markdown's `<h2>`s with a tiny regex pass — no shared file touches.
7 *
8 * All CSS is scoped under `.legal-page-*` so it cannot bleed into the
9 * sub-routes under `/legal/terms` etc. (which render their own JSX).
310 */
411
512import { Hono } from "hono";
1522
1623legal.use("*", softAuth);
1724
18function serveLegalPage(title: string, filename: string) {
25// ─── Scoped CSS ─────────────────────────────────────────────────────────────
26const legalStyles = `
27 .legal-page-wrap {
28 max-width: 880px;
29 margin: 0 auto;
30 padding: var(--space-6) var(--space-4);
31 }
32
33 /* ─── Hero ─── */
34 .legal-page-hero {
35 position: relative;
36 margin-bottom: var(--space-5);
37 padding: var(--space-5) var(--space-6);
38 background: var(--bg-elevated);
39 border: 1px solid var(--border);
40 border-radius: 16px;
41 overflow: hidden;
42 }
43 .legal-page-hero::before {
44 content: '';
45 position: absolute;
46 top: 0; left: 0; right: 0;
47 height: 2px;
48 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
49 opacity: 0.78;
50 pointer-events: none;
51 }
52 .legal-page-hero-orb {
53 position: absolute;
54 inset: -22% -10% auto auto;
55 width: 380px; height: 380px;
56 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
57 filter: blur(80px);
58 opacity: 0.7;
59 pointer-events: none;
60 z-index: 0;
61 }
62 .legal-page-hero-inner { position: relative; z-index: 1; max-width: 720px; }
63 .legal-page-eyebrow {
64 display: inline-flex;
65 align-items: center;
66 gap: 8px;
67 font-family: var(--font-mono);
68 font-size: 11.5px;
69 text-transform: uppercase;
70 letter-spacing: 0.14em;
71 color: var(--text-muted);
72 font-weight: 600;
73 margin-bottom: 14px;
74 }
75 .legal-page-eyebrow-pill {
76 display: inline-flex;
77 align-items: center;
78 justify-content: center;
79 width: 18px; height: 18px;
80 border-radius: 6px;
81 background: rgba(140,109,255,0.14);
82 color: #b69dff;
83 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
84 }
85 .legal-page-title {
86 font-size: clamp(28px, 4vw, 40px);
87 font-family: var(--font-display);
88 font-weight: 800;
89 letter-spacing: -0.028em;
90 line-height: 1.05;
91 margin: 0 0 var(--space-2);
92 color: var(--text-strong);
93 }
94 .legal-page-title-grad {
95 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
96 -webkit-background-clip: text;
97 background-clip: text;
98 -webkit-text-fill-color: transparent;
99 color: transparent;
100 }
101 .legal-page-sub {
102 font-size: 15px;
103 color: var(--text-muted);
104 margin: 0;
105 line-height: 1.55;
106 }
107
108 /* ─── Table of contents ─── */
109 .legal-page-toc {
110 position: relative;
111 margin-bottom: var(--space-5);
112 padding: var(--space-4) var(--space-5);
113 background: var(--bg-elevated);
114 border: 1px solid var(--border);
115 border-radius: 14px;
116 overflow: hidden;
117 }
118 .legal-page-toc::before {
119 content: '';
120 position: absolute;
121 left: 0; top: 0; bottom: 0;
122 width: 3px;
123 background: linear-gradient(180deg, #8c6dff 0%, #36c5d6 100%);
124 }
125 .legal-page-toc-label {
126 font-family: var(--font-mono);
127 font-size: 11px;
128 text-transform: uppercase;
129 letter-spacing: 0.12em;
130 color: var(--accent);
131 margin-bottom: var(--space-3);
132 font-weight: 600;
133 }
134 .legal-page-toc-list {
135 list-style: none;
136 margin: 0;
137 padding: 0;
138 columns: 2;
139 column-gap: 28px;
140 }
141 @media (max-width: 720px) {
142 .legal-page-toc-list { columns: 1; }
143 }
144 .legal-page-toc-list li {
145 margin-bottom: 6px;
146 font-family: var(--font-mono);
147 font-size: 12.5px;
148 break-inside: avoid;
149 }
150 .legal-page-toc-list a {
151 color: var(--text);
152 text-decoration: none;
153 transition: color 120ms ease;
154 display: inline-block;
155 }
156 .legal-page-toc-list a:hover { color: var(--accent); text-decoration: underline; }
157 .legal-page-toc-list .num {
158 color: var(--text-faint);
159 margin-right: 8px;
160 font-variant-numeric: tabular-nums;
161 }
162
163 /* ─── Body card ─── */
164 .legal-page-body {
165 padding: var(--space-6) clamp(var(--space-4), 3vw, var(--space-6));
166 background: var(--bg-elevated);
167 border: 1px solid var(--border);
168 border-radius: 14px;
169 }
170 .legal-page-body .markdown-body { background: transparent; }
171 .legal-page-body .markdown-body h1:first-child { display: none; }
172 .legal-page-body .markdown-body h2 {
173 scroll-margin-top: 72px;
174 }
175
176 .legal-page-foot {
177 margin-top: var(--space-5);
178 padding: var(--space-4);
179 text-align: center;
180 color: var(--text-muted);
181 font-size: 13px;
182 border: 1px dashed var(--border);
183 border-radius: 12px;
184 }
185 .legal-page-foot a {
186 color: var(--accent);
187 text-decoration: none;
188 font-weight: 600;
189 }
190 .legal-page-foot a:hover { text-decoration: underline; }
191`;
192
193/**
194 * Build a deterministic slug for a heading. Matches the post-process pass
195 * below so the TOC anchors hit their targets.
196 */
197function slugify(text: string): string {
198 return text
199 .toLowerCase()
200 .replace(/<[^>]+>/g, "")
201 .replace(/&[a-z]+;/g, "")
202 .replace(/[^\w\s-]/g, "")
203 .trim()
204 .replace(/\s+/g, "-");
205}
206
207/**
208 * Pull `## Heading` lines from the raw markdown and synth a TOC. Returns an
209 * empty list if no h2s are found, in which case the renderer skips the TOC
210 * card entirely.
211 */
212function extractToc(markdown: string): Array<{ id: string; text: string }> {
213 const out: Array<{ id: string; text: string }> = [];
214 const seen = new Map<string, number>();
215 const re = /^##\s+(.+?)\s*$/gm;
216 let m: RegExpExecArray | null;
217 while ((m = re.exec(markdown)) !== null) {
218 const text = m[1]!.trim();
219 let id = slugify(text);
220 const n = seen.get(id) ?? 0;
221 seen.set(id, n + 1);
222 if (n > 0) id = `${id}-${n}`;
223 out.push({ id, text });
224 }
225 return out;
226}
227
228/**
229 * Inject `id="..."` into rendered `<h2>` tags so the TOC anchors land.
230 * marked doesn't add heading ids out of the box and we don't want to touch
231 * the shared `lib/markdown.ts` for a single page.
232 */
233function addHeadingIds(htmlString: string): string {
234 const seen = new Map<string, number>();
235 return htmlString.replace(/<h2>([^<]+)<\/h2>/g, (_full, inner) => {
236 let id = slugify(inner);
237 const n = seen.get(id) ?? 0;
238 seen.set(id, n + 1);
239 if (n > 0) id = `${id}-${n}`;
240 return `<h2 id="${id}">${inner}</h2>`;
241 });
242}
243
244function getPageMeta(slug: string): { eyebrow: string; title: string; grad: string; sub: string } {
245 switch (slug) {
246 case "terms":
247 return {
248 eyebrow: "Legal · Terms of Service",
249 title: "The rules we both",
250 grad: "agree to.",
251 sub: "Last updated April 2026. Plain-English where we can, lawyer-English where we must.",
252 };
253 case "privacy":
254 return {
255 eyebrow: "Legal · Privacy Policy",
256 title: "What we collect, why,",
257 grad: "and how to make us stop.",
258 sub: "We collect the minimum we need to run the platform. You can export or delete your data at any time.",
259 };
260 case "acceptable-use":
261 return {
262 eyebrow: "Legal · Acceptable Use",
263 title: "What you can",
264 grad: "build here.",
265 sub: "Short list: almost anything. Don't host malware, don't dox people, don't break the law.",
266 };
267 default:
268 return {
269 eyebrow: "Legal",
270 title: "Policy",
271 grad: "document.",
272 sub: "",
273 };
274 }
275}
276
277function serveLegalPage(title: string, slug: string, filename: string) {
19278 return async (c: any) => {
20279 const user = c.get("user");
21280 let content: string;
28287 content = `# ${title}\n\nThis page is being prepared. Check back soon.`;
29288 }
30289
31 const rendered = renderMarkdown(content);
290 const rendered = addHeadingIds(renderMarkdown(content));
291 const toc = extractToc(content);
292 const meta = getPageMeta(slug);
32293
33294 return c.html(
34295 <Layout title={title} user={user}>
35296 <style>{markdownCss}</style>
36 <div class="markdown-body" style="max-width: 800px; margin: 0 auto">
37 {html([rendered] as unknown as TemplateStringsArray)}
297 <style dangerouslySetInnerHTML={{ __html: legalStyles }} />
298 <div class={`legal-page-wrap legal-page-${slug}`}>
299 {/* ─── Hero ─── */}
300 <section class="legal-page-hero">
301 <div class="legal-page-hero-orb" aria-hidden="true" />
302 <div class="legal-page-hero-inner">
303 <div class="legal-page-eyebrow">
304 <span class="legal-page-eyebrow-pill" aria-hidden="true">
305 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
306 <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
307 <polyline points="14 2 14 8 20 8" />
308 </svg>
309 </span>
310 {meta.eyebrow}
311 </div>
312 <h1 class="legal-page-title">
313 {meta.title}{" "}
314 <span class="legal-page-title-grad">{meta.grad}</span>
315 </h1>
316 <p class="legal-page-sub">{meta.sub}</p>
317 </div>
318 </section>
319
320 {/* ─── Table of contents ─── */}
321 {toc.length > 0 && (
322 <nav class="legal-page-toc" aria-label="Table of contents">
323 <div class="legal-page-toc-label">Contents</div>
324 <ol class="legal-page-toc-list">
325 {toc.map((item, i) => (
326 <li>
327 <a href={`#${item.id}`}>
328 <span class="num">
329 {String(i + 1).padStart(2, "0")}
330 </span>
331 {item.text}
332 </a>
333 </li>
334 ))}
335 </ol>
336 </nav>
337 )}
338
339 {/* ─── Body ─── */}
340 <article class="legal-page-body">
341 <div class="markdown-body">
342 {html([rendered] as unknown as TemplateStringsArray)}
343 </div>
344 </article>
345
346 <div class="legal-page-foot">
347 Questions? Email{" "}
348 <a href="mailto:legal@gluecron.com">legal@gluecron.com</a>{" "}
349 · Other policies:{" "}
350 <a href="/terms">Terms</a> · <a href="/privacy">Privacy</a> ·{" "}
351 <a href="/acceptable-use">AUP</a>
352 </div>
38353 </div>
39354 </Layout>
40355 );
41356 };
42357}
43358
44legal.get("/terms", serveLegalPage("Terms of Service", "TERMS.md"));
45legal.get("/privacy", serveLegalPage("Privacy Policy", "PRIVACY.md"));
46legal.get("/acceptable-use", serveLegalPage("Acceptable Use Policy", "AUP.md"));
359legal.get("/terms", serveLegalPage("Terms of Service", "terms", "TERMS.md"));
360legal.get("/privacy", serveLegalPage("Privacy Policy", "privacy", "PRIVACY.md"));
361legal.get("/acceptable-use", serveLegalPage("Acceptable Use Policy", "acceptable-use", "AUP.md"));
47362
48363export default legal;
Modifiedsrc/routes/migrations.tsx+563−155View fileUnifiedSplit
1313 * supplied by a parallel agent. We load it via dynamic import inside a
1414 * try/catch so a missing module produces a helpful "verifier not available"
1515 * note instead of a 500.
16 *
17 * 2026 polish:
18 * - Scoped `.mig-*` CSS — no bleed into the global layout.
19 * - Eyebrow + display headline + 1-line subtitle.
20 * - Each migration row is a card: run name, applied/pending/failed pill,
21 * timestamp, duration (best-effort).
22 * - Dashed empty-state with orb + CTA when no migrations exist.
23 * - All query params + form actions preserved verbatim.
1624 */
1725
1826import { Hono } from "hono";
2937migrations.use("/migrations", requireAuth);
3038migrations.use("/migrations/*", requireAuth);
3139
40// ─── Scoped CSS (.mig-*) ────────────────────────────────────────────────
41const migStyles = `
42 .mig-wrap { max-width: 980px; margin: 0 auto; padding: var(--space-5) var(--space-4) var(--space-8); }
43
44 .mig-head {
45 margin-bottom: var(--space-5);
46 display: flex;
47 align-items: flex-end;
48 justify-content: space-between;
49 gap: var(--space-4);
50 flex-wrap: wrap;
51 }
52 .mig-head-text { flex: 1; min-width: 280px; }
53 .mig-eyebrow {
54 display: inline-flex;
55 align-items: center;
56 gap: 8px;
57 text-transform: uppercase;
58 font-family: var(--font-mono);
59 font-size: 11px;
60 letter-spacing: 0.16em;
61 color: var(--text-muted);
62 font-weight: 600;
63 margin-bottom: 10px;
64 }
65 .mig-eyebrow-dot {
66 width: 8px; height: 8px;
67 border-radius: 9999px;
68 background: linear-gradient(135deg, #8c6dff, #36c5d6);
69 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
70 }
71 .mig-title {
72 font-family: var(--font-display);
73 font-size: clamp(24px, 3.4vw, 36px);
74 font-weight: 800;
75 letter-spacing: -0.028em;
76 line-height: 1.1;
77 margin: 0 0 6px;
78 color: var(--text-strong);
79 }
80 .mig-title-grad {
81 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
82 -webkit-background-clip: text;
83 background-clip: text;
84 -webkit-text-fill-color: transparent;
85 color: transparent;
86 }
87 .mig-sub {
88 margin: 0;
89 font-size: 14px;
90 color: var(--text-muted);
91 line-height: 1.5;
92 max-width: 720px;
93 }
94
95 .mig-actions { display: flex; gap: 8px; flex-wrap: wrap; }
96 .mig-btn {
97 display: inline-flex;
98 align-items: center;
99 justify-content: center;
100 gap: 6px;
101 padding: 9px 16px;
102 border-radius: 10px;
103 font-size: 13px;
104 font-weight: 600;
105 text-decoration: none;
106 border: 1px solid transparent;
107 cursor: pointer;
108 font: inherit;
109 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
110 line-height: 1;
111 white-space: nowrap;
112 }
113 .mig-btn-primary {
114 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
115 color: #ffffff;
116 box-shadow: 0 6px 18px -6px rgba(140,109,255,0.50), inset 0 1px 0 rgba(255,255,255,0.16);
117 }
118 .mig-btn-primary:hover {
119 transform: translateY(-1px);
120 box-shadow: 0 10px 24px -8px rgba(140,109,255,0.60), inset 0 1px 0 rgba(255,255,255,0.20);
121 text-decoration: none;
122 color: #ffffff;
123 }
124 .mig-btn-ghost {
125 background: transparent;
126 color: var(--text);
127 border-color: var(--border-strong);
128 }
129 .mig-btn-ghost:hover {
130 background: rgba(140,109,255,0.06);
131 border-color: rgba(140,109,255,0.45);
132 color: var(--text-strong);
133 text-decoration: none;
134 }
135
136 /* ─── Migration row cards ─── */
137 .mig-list {
138 display: flex;
139 flex-direction: column;
140 gap: 10px;
141 }
142 .mig-row {
143 position: relative;
144 display: grid;
145 grid-template-columns: 1fr auto;
146 gap: var(--space-3);
147 padding: 14px 16px;
148 background: var(--bg-elevated);
149 border: 1px solid var(--border);
150 border-radius: 12px;
151 transition: border-color 120ms ease, background 120ms ease;
152 overflow: hidden;
153 }
154 .mig-row::before {
155 content: '';
156 position: absolute;
157 top: 0; left: 0; right: 0;
158 height: 1.5px;
159 background: linear-gradient(90deg, transparent 0%, #8c6dff 50%, #36c5d6 100%);
160 opacity: 0.35;
161 pointer-events: none;
162 }
163 .mig-row:hover {
164 border-color: var(--border-strong);
165 background: rgba(255,255,255,0.025);
166 }
167 .mig-row-main { min-width: 0; }
168 .mig-row-name-line {
169 display: flex;
170 align-items: center;
171 gap: 8px;
172 flex-wrap: wrap;
173 margin-bottom: 6px;
174 }
175 .mig-row-name {
176 font-family: var(--font-display);
177 font-size: 15.5px;
178 font-weight: 700;
179 color: var(--text-strong);
180 text-decoration: none;
181 letter-spacing: -0.012em;
182 }
183 .mig-row-name:hover { text-decoration: underline; }
184 .mig-row-desc {
185 font-size: 12.5px;
186 color: var(--text-muted);
187 line-height: 1.5;
188 margin: 0;
189 overflow: hidden;
190 text-overflow: ellipsis;
191 white-space: nowrap;
192 }
193 .mig-row-meta {
194 display: flex;
195 align-items: center;
196 gap: 8px;
197 flex-wrap: wrap;
198 margin-top: 8px;
199 font-size: 11.5px;
200 color: var(--text-muted);
201 font-variant-numeric: tabular-nums;
202 }
203 .mig-row-meta .sep { opacity: 0.4; }
204 .mig-row-actions {
205 display: flex;
206 align-items: center;
207 gap: 6px;
208 flex-shrink: 0;
209 }
210
211 /* ─── Status pills ─── */
212 .mig-pill {
213 display: inline-flex;
214 align-items: center;
215 gap: 6px;
216 padding: 3px 9px;
217 border-radius: 9999px;
218 font-size: 11px;
219 font-weight: 600;
220 letter-spacing: 0.02em;
221 text-transform: capitalize;
222 }
223 .mig-pill .dot { width: 6px; height: 6px; border-radius: 9999px; background: currentColor; }
224 .mig-pill.is-applied {
225 background: rgba(52,211,153,0.14);
226 color: #6ee7b7;
227 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.32);
228 }
229 .mig-pill.is-pending {
230 background: rgba(251,191,36,0.12);
231 color: #fde68a;
232 box-shadow: inset 0 0 0 1px rgba(251,191,36,0.32);
233 }
234 .mig-pill.is-failed {
235 background: rgba(248,113,113,0.12);
236 color: #fecaca;
237 box-shadow: inset 0 0 0 1px rgba(248,113,113,0.32);
238 }
239
240 /* ─── Banners (used on verify page) ─── */
241 .mig-banner {
242 margin-bottom: var(--space-4);
243 padding: 10px 14px;
244 border-radius: 10px;
245 font-size: 13.5px;
246 border: 1px solid var(--border);
247 background: rgba(255,255,255,0.025);
248 color: var(--text);
249 display: flex;
250 align-items: center;
251 gap: 10px;
252 }
253 .mig-banner.is-ok { border-color: rgba(52,211,153,0.40); background: rgba(52,211,153,0.08); color: #bbf7d0; }
254 .mig-banner.is-error { border-color: rgba(248,113,113,0.40); background: rgba(248,113,113,0.08); color: #fecaca; }
255 .mig-banner.is-warn { border-color: rgba(251,191,36,0.32); background: rgba(251,191,36,0.06); color: #fde68a; }
256 .mig-banner-dot { width: 8px; height: 8px; border-radius: 9999px; background: currentColor; flex-shrink: 0; }
257
258 .mig-checks { display: flex; flex-direction: column; gap: 10px; }
259 .mig-check {
260 display: flex;
261 align-items: center;
262 justify-content: space-between;
263 gap: 12px;
264 padding: 12px 14px;
265 background: var(--bg-elevated);
266 border: 1px solid var(--border);
267 border-radius: 10px;
268 }
269 .mig-check-label {
270 display: flex;
271 align-items: center;
272 gap: 10px;
273 font-family: var(--font-display);
274 font-weight: 700;
275 color: var(--text-strong);
276 font-size: 13.5px;
277 }
278 .mig-check-dot {
279 width: 10px; height: 10px;
280 border-radius: 9999px;
281 flex-shrink: 0;
282 }
283 .mig-check-dot.is-ok { background: #34d399; box-shadow: 0 0 0 3px rgba(52,211,153,0.18); }
284 .mig-check-dot.is-fail { background: #f87171; box-shadow: 0 0 0 3px rgba(248,113,113,0.18); }
285 .mig-check-detail {
286 font-size: 12px;
287 color: var(--text-muted);
288 }
289 .mig-issues-list {
290 margin: 0;
291 padding-left: 20px;
292 color: var(--text-muted);
293 font-size: 13px;
294 line-height: 1.6;
295 }
296
297 /* ─── Empty state ─── */
298 .mig-empty {
299 position: relative;
300 overflow: hidden;
301 padding: clamp(28px, 5vw, 52px) clamp(20px, 4vw, 40px);
302 text-align: center;
303 background: var(--bg-elevated);
304 border: 1px dashed var(--border-strong);
305 border-radius: 16px;
306 }
307 .mig-empty-orb {
308 position: absolute;
309 inset: -40% 25% auto 25%;
310 height: 300px;
311 background: radial-gradient(circle, rgba(140,109,255,0.18), rgba(54,197,214,0.10) 45%, transparent 70%);
312 filter: blur(72px);
313 opacity: 0.7;
314 pointer-events: none;
315 z-index: 0;
316 }
317 .mig-empty-inner { position: relative; z-index: 1; }
318 .mig-empty-icon {
319 width: 56px; height: 56px;
320 margin: 0 auto 14px;
321 border-radius: 9999px;
322 background: linear-gradient(135deg, rgba(140,109,255,0.25), rgba(54,197,214,0.20));
323 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.40);
324 display: inline-flex;
325 align-items: center;
326 justify-content: center;
327 color: #c4b5fd;
328 }
329 .mig-empty-title {
330 font-family: var(--font-display);
331 font-size: 18px;
332 font-weight: 700;
333 margin: 0 0 6px;
334 color: var(--text-strong);
335 }
336 .mig-empty-sub {
337 margin: 0 auto 16px;
338 font-size: 13.5px;
339 color: var(--text-muted);
340 max-width: 440px;
341 line-height: 1.5;
342 }
343 .mig-crumbs {
344 margin-bottom: var(--space-4);
345 font-size: 12.5px;
346 color: var(--text-muted);
347 }
348 .mig-crumbs a {
349 display: inline-flex;
350 align-items: center;
351 gap: 5px;
352 padding: 6px 11px;
353 background: rgba(255,255,255,0.025);
354 border: 1px solid var(--border);
355 border-radius: 8px;
356 color: var(--text-muted);
357 text-decoration: none;
358 font-weight: 500;
359 }
360 .mig-crumbs a:hover { border-color: var(--border-strong); color: var(--text-strong); text-decoration: none; }
361`;
362
363function IconImport() {
364 return (
365 <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">
366 <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
367 <polyline points="7 10 12 15 17 10" />
368 <line x1="12" y1="15" x2="12" y2="3" />
369 </svg>
370 );
371}
372function IconShield() {
373 return (
374 <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">
375 <path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z" />
376 </svg>
377 );
378}
379
32380// ─── Verifier loader ─────────────────────────────────────────
33//
34// The verifier is optional at app boot — a parallel agent owns the file.
35// We load it dynamically so this route works whether or not the module
36// is present on disk. The expected interface is:
37//
38// export async function verifyMigration(repoId: number): Promise<{
39// repoId: number;
40// clonable: boolean;
41// hasDefaultBranch: boolean;
42// commitCount: number;
43// issues: string[];
44// }>
45//
46381type VerifyResult = {
47382 repoId: number;
48383 clonable: boolean;
55390 ((repoId: number) => Promise<VerifyResult>) | null
56391> {
57392 try {
58 // eslint-disable-next-line @typescript-eslint/no-var-requires
59393 const mod: any = await import("../lib/import-verify");
60394 if (mod && typeof mod.verifyMigration === "function") {
61395 return mod.verifyMigration as (id: number) => Promise<VerifyResult>;
70404migrations.get("/migrations", async (c) => {
71405 const user = c.get("user")!;
72406
73 // Best-effort listing: all repos owned by the user, newest first.
74 // When an `importedAt` column is added later, narrow this WHERE to
75 // `and(eq(ownerId, user.id), isNotNull(importedAt))`.
76407 let rows: Array<{
77408 id: string;
78409 name: string;
97428
98429 return c.html(
99430 <Layout title="Migration history" user={user}>
100 <div style="max-width: 900px; margin: 0 auto; padding: 24px">
101 <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:16px">
102 <h1 style="margin:0">Migration history</h1>
103 <div style="display:flex;gap:8px">
104 <a class="btn" href="/import">
431 <div class="mig-wrap">
432 <header class="mig-head">
433 <div class="mig-head-text">
434 <div class="mig-eyebrow">
435 <span class="mig-eyebrow-dot" aria-hidden="true" />
436 Account · Migration history
437 </div>
438 <h1 class="mig-title">
439 <span class="mig-title-grad">Every repo you brought over.</span>
440 </h1>
441 <p class="mig-sub">
442 Re-run the post-migration verifier any time — it confirms the
443 repo is clonable, the default branch resolves, and commits are
444 present.
445 </p>
446 </div>
447 <div class="mig-actions">
448 <a class="mig-btn mig-btn-ghost" href="/import">
449 <IconImport />
105450 Import
106451 </a>
107 <a class="btn" href="/import/bulk">
452 <a class="mig-btn mig-btn-primary" href="/import/bulk">
453 <IconImport />
108454 Bulk import
109455 </a>
110456 </div>
111 </div>
112
113 <p style="color: var(--text-muted); margin-bottom: 16px">
114 Repositories you've migrated to gluecron. Use <strong>Verify</strong>
115 {" "}to re-run the post-migration check (clonability, default branch,
116 commit count).
117 </p>
457 </header>
118458
119459 {rows.length === 0 ? (
120 <div class="panel-empty" style="padding: 32px; text-align: center">
121 You haven't migrated any repos yet. Try{" "}
122 <a href="/import">/import</a> or{" "}
123 <a href="/import/bulk">/import/bulk</a>.
460 <div class="mig-empty">
461 <div class="mig-empty-orb" aria-hidden="true" />
462 <div class="mig-empty-inner">
463 <div class="mig-empty-icon" aria-hidden="true">
464 <IconImport />
465 </div>
466 <h3 class="mig-empty-title">No migrations yet</h3>
467 <p class="mig-empty-sub">
468 Pull a single repo from GitHub or kick off a bulk org import to
469 see migration runs land here.
470 </p>
471 <div class="mig-actions" style="justify-content:center">
472 <a class="mig-btn mig-btn-ghost" href="/import">
473 <IconImport />
474 Single repo
475 </a>
476 <a class="mig-btn mig-btn-primary" href="/import/bulk">
477 <IconImport />
478 Bulk import
479 </a>
480 </div>
481 </div>
124482 </div>
125483 ) : (
126 <div class="panel">
127 <div
128 class="panel-item"
129 style="font-weight:600;background:var(--bg-subtle)"
130 >
131 <div style="flex:2;min-width:0">Repo</div>
132 <div style="flex:2;min-width:0">Source</div>
133 <div style="flex:1;min-width:0">Imported at</div>
134 <div style="width:120px;text-align:right">Action</div>
135 </div>
136 {rows.map((r) => (
137 <div class="panel-item">
138 <div style="flex:2;min-width:0;overflow:hidden;text-overflow:ellipsis">
139 <a href={`/${user.username}/${r.name}`}>
140 <strong>{r.name}</strong>
141 </a>
142 {r.description && (
143 <div
144 style="font-size:12px;color:var(--text-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis"
145 >
146 {r.description}
484 <div class="mig-list">
485 {rows.map((r) => {
486 // The schema has no migration-status column yet — every repo
487 // that exists has, by definition, been "applied". We expose the
488 // pill shape so when status lands later we can swap the value.
489 const status: "applied" | "pending" | "failed" = "applied";
490 const pillClass =
491 status === "applied"
492 ? "mig-pill is-applied"
493 : status === "pending"
494 ? "mig-pill is-pending"
495 : "mig-pill is-failed";
496 const tsText = r.createdAt
497 ? formatRelative(r.createdAt as unknown as string)
498 : "—";
499 // Duration not tracked in the schema; render an em-dash so the
500 // visual treatment is in place for when it lands.
501 return (
502 <div class="mig-row">
503 <div class="mig-row-main">
504 <div class="mig-row-name-line">
505 <a
506 href={`/${user.username}/${r.name}`}
507 class="mig-row-name"
508 >
509 {r.name}
510 </a>
511 <span class={pillClass}>
512 <span class="dot" aria-hidden="true" />
513 {status}
514 </span>
147515 </div>
148 )}
149 </div>
150 <div
151 style="flex:2;min-width:0;font-size:12px;color:var(--text-muted);font-family:var(--font-mono);overflow:hidden;text-overflow:ellipsis"
152 >
153 {/* Source URL column — we don't persist the upstream URL
154 yet, so show a neutral placeholder. */}
155
156 </div>
157 <div style="flex:1;min-width:0;font-size:12px;color:var(--text-muted)">
158 {r.createdAt ? formatRelative(r.createdAt as unknown as string) : "—"}
159 </div>
160 <div style="width:120px;text-align:right">
161 <a
162 class="btn btn-primary"
163 href={`/migrations/verify/${r.id}`}
164 >
165 Verify
166 </a>
516 {r.description && (
517 <p class="mig-row-desc">{r.description}</p>
518 )}
519 <div class="mig-row-meta">
520 <span>Imported {tsText}</span>
521 <span class="sep">·</span>
522 <span>duration —</span>
523 </div>
524 </div>
525 <div class="mig-row-actions">
526 <a
527 class="mig-btn mig-btn-primary"
528 href={`/migrations/verify/${r.id}`}
529 >
530 <IconShield />
531 Verify
532 </a>
533 </div>
167534 </div>
168 </div>
169 ))}
535 );
536 })}
170537 </div>
171538 )}
172539 </div>
540 <style dangerouslySetInnerHTML={{ __html: migStyles }} />
173541 </Layout>
174542 );
175543});
179547 const user = c.get("user")!;
180548 const repoId = c.req.param("repoId");
181549
182 // Ownership check: the verifier must only run for repos this user owns.
183550 let repo:
184551 | {
185552 id: string;
207574 if (!repo) {
208575 return c.html(
209576 <Layout title="Verify migration" user={user}>
210 <div style="max-width: 700px; margin: 0 auto; padding: 24px">
211 <h1>Repository not found</h1>
212 <p style="color:var(--text-muted)">
213 <a href="/migrations">Back to migration history</a>
214 </p>
577 <div class="mig-wrap">
578 <div class="mig-crumbs">
579 <a href="/migrations">← Back to migration history</a>
580 </div>
581 <header class="mig-head">
582 <div class="mig-head-text">
583 <div class="mig-eyebrow">
584 <span class="mig-eyebrow-dot" aria-hidden="true" />
585 Verify migration
586 </div>
587 <h1 class="mig-title">
588 <span class="mig-title-grad">Repository not found.</span>
589 </h1>
590 <p class="mig-sub">
591 The repo ID didn't resolve — it may have been deleted or the
592 URL is stale.
593 </p>
594 </div>
595 </header>
215596 </div>
597 <style dangerouslySetInnerHTML={{ __html: migStyles }} />
216598 </Layout>,
217599 404
218600 );
221603 if (repo.ownerId !== user.id) {
222604 return c.html(
223605 <Layout title="Verify migration" user={user}>
224 <div style="max-width: 700px; margin: 0 auto; padding: 24px">
225 <h1>Forbidden</h1>
226 <p style="color:var(--text-muted)">
227 You can only verify repositories you own.
228 </p>
229 <p>
230 <a href="/migrations">Back to migration history</a>
231 </p>
606 <div class="mig-wrap">
607 <div class="mig-crumbs">
608 <a href="/migrations">← Back to migration history</a>
609 </div>
610 <header class="mig-head">
611 <div class="mig-head-text">
612 <div class="mig-eyebrow">
613 <span class="mig-eyebrow-dot" aria-hidden="true" />
614 Forbidden
615 </div>
616 <h1 class="mig-title">
617 <span class="mig-title-grad">Not your repo.</span>
618 </h1>
619 <p class="mig-sub">
620 You can only verify repositories you own.
621 </p>
622 </div>
623 </header>
232624 </div>
625 <style dangerouslySetInnerHTML={{ __html: migStyles }} />
233626 </Layout>,
234627 403
235628 );
243636 "Verifier not available. The import-verify module is not installed yet.";
244637 } else {
245638 try {
246 // Schema stores repo id as uuid string; the verifier interface
247 // types it as `number` but many callers pass through strings. Cast
248 // defensively so we don't crash on either shape.
249639 result = await verify(repo.id as unknown as number);
250640 } catch (err: any) {
251641 verifierError =
253643 }
254644 }
255645
256 const indicator = (ok: boolean) => (
257 <span
258 style={`display:inline-block;width:10px;height:10px;border-radius:50%;margin-right:6px;background:${
259 ok ? "var(--green, #2ea043)" : "var(--red, #f85149)"
260 }`}
261 />
262 );
263
264646 return c.html(
265647 <Layout title={`Verify ${repo.name}`} user={user}>
266 <div style="max-width: 700px; margin: 0 auto; padding: 24px">
267 <div style="margin-bottom: 12px">
268 <a href="/migrations" style="font-size:12px">
269 ← Migration history
270 </a>
271 </div>
272 <h1 style="margin:0 0 4px 0">Verify migration</h1>
273 <div style="color:var(--text-muted);font-family:var(--font-mono);margin-bottom:16px">
274 {user.username}/{repo.name}
648 <div class="mig-wrap">
649 <div class="mig-crumbs">
650 <a href="/migrations">← Back to migration history</a>
275651 </div>
652 <header class="mig-head">
653 <div class="mig-head-text">
654 <div class="mig-eyebrow">
655 <span class="mig-eyebrow-dot" aria-hidden="true" />
656 Migration · Verify run
657 </div>
658 <h1 class="mig-title">
659 <span class="mig-title-grad">
660 {user.username}/{repo.name}
661 </span>
662 </h1>
663 <p class="mig-sub">
664 Re-runs the post-migration verifier. We confirm clonability, the
665 default branch, and the commit count.
666 </p>
667 </div>
668 <div class="mig-actions">
669 <a
670 class="mig-btn mig-btn-primary"
671 href={`/migrations/verify/${repo.id}`}
672 >
673 <IconShield />
674 Re-run
675 </a>
676 <a class="mig-btn mig-btn-ghost" href="/migrations">
677 Back
678 </a>
679 </div>
680 </header>
276681
277682 {verifierError && (
278 <div
279 class="panel-empty"
280 style="padding:16px;border-left:3px solid var(--red, #f85149)"
281 >
683 <div class="mig-banner is-warn" role="status">
684 <span class="mig-banner-dot" aria-hidden="true" />
282685 {verifierError}
283686 </div>
284687 )}
285688
286689 {result && (
287 <div class="panel">
288 <div class="panel-item">
289 <div style="flex:1">
290 {indicator(result.clonable)}
291 <strong>Clonable</strong>
690 <div class="mig-checks">
691 <div class="mig-check">
692 <div class="mig-check-label">
693 <span
694 class={
695 "mig-check-dot " +
696 (result.clonable ? "is-ok" : "is-fail")
697 }
698 aria-hidden="true"
699 />
700 Clonable
292701 </div>
293 <div style="color:var(--text-muted);font-size:12px">
294 {result.clonable ? "Repository responds to git clone" : "Clone failed"}
702 <div class="mig-check-detail">
703 {result.clonable
704 ? "Repository responds to git clone"
705 : "Clone failed"}
295706 </div>
296707 </div>
297 <div class="panel-item">
298 <div style="flex:1">
299 {indicator(result.hasDefaultBranch)}
300 <strong>Default branch</strong>
708 <div class="mig-check">
709 <div class="mig-check-label">
710 <span
711 class={
712 "mig-check-dot " +
713 (result.hasDefaultBranch ? "is-ok" : "is-fail")
714 }
715 aria-hidden="true"
716 />
717 Default branch
301718 </div>
302 <div style="color:var(--text-muted);font-size:12px">
719 <div class="mig-check-detail">
303720 {result.hasDefaultBranch
304721 ? `Found ${repo.defaultBranch}`
305722 : `Missing ${repo.defaultBranch}`}
306723 </div>
307724 </div>
308 <div class="panel-item">
309 <div style="flex:1">
310 {indicator(result.commitCount > 0)}
311 <strong>Commits</strong>
725 <div class="mig-check">
726 <div class="mig-check-label">
727 <span
728 class={
729 "mig-check-dot " +
730 (result.commitCount > 0 ? "is-ok" : "is-fail")
731 }
732 aria-hidden="true"
733 />
734 Commits
312735 </div>
313 <div style="color:var(--text-muted);font-size:12px">
736 <div class="mig-check-detail">
314737 {result.commitCount} commit
315738 {result.commitCount === 1 ? "" : "s"}
316739 </div>
317740 </div>
318741 {result.issues && result.issues.length > 0 && (
319 <div
320 class="panel-item"
321 style="flex-direction:column;align-items:stretch;gap:4px"
322 >
323 <div>
324 {indicator(false)}
325 <strong>Issues</strong>
742 <div class="mig-check" style="flex-direction:column;align-items:stretch;gap:8px">
743 <div class="mig-check-label">
744 <span class="mig-check-dot is-fail" aria-hidden="true" />
745 Issues
326746 </div>
327 <ul style="margin:0;padding-left:20px;color:var(--text-muted);font-size:13px">
747 <ul class="mig-issues-list">
328748 {result.issues.map((i) => (
329749 <li>{i}</li>
330750 ))}
335755 result.clonable &&
336756 result.hasDefaultBranch &&
337757 result.commitCount > 0 && (
338 <div class="panel-item">
339 <div style="flex:1;color:var(--green, #2ea043)">
340 All checks passed.
341 </div>
758 <div class="mig-banner is-ok" role="status">
759 <span class="mig-banner-dot" aria-hidden="true" />
760 All checks passed.
342761 </div>
343762 )}
344763 </div>
345764 )}
346
347 <div style="margin-top:16px;display:flex;gap:8px">
348 <a
349 class="btn btn-primary"
350 href={`/migrations/verify/${repo.id}`}
351 >
352 Re-run verification
353 </a>
354 <a class="btn" href="/migrations">
355 Back
356 </a>
357 </div>
358765 </div>
766 <style dangerouslySetInnerHTML={{ __html: migStyles }} />
359767 </Layout>
360768 );
361769});
Modifiedsrc/routes/sleep-mode.tsx+688−135View fileUnifiedSplit
22 * Block L1Sleep Mode marketing page.
33 *
44 * Public, no auth. Pitch: "Toggle Sleep Mode. Walk away. Wake up to a
5 * digest of what Claude shipped overnight." Three-step "how it works",
6 * a sample digest rendered from a synthetic `SleepModeReport`, and a
7 * CTA to /settings.
5 * digest of what Claude shipped overnight." Status card surfaces the
6 * viewer's own toggle (Active / Inactive) when signed in, a config card
7 * mirrors the upcoming threshold form fields, an excluded-repos section
8 * card stubs the per-repo opt-out list, and a sample digest renders from
9 * a synthetic `SleepModeReport`.
10 *
11 * The form/card UIs are read-only previews — the real mutation lives in
12 * `/settings`. We render them here so the marketing page mirrors the
13 * shape an operator will see once they enable the feature. CSS scoped
14 * under `.sleep-` so it can't bleed into other surfaces.
815 */
916
1017import { Hono } from "hono";
5360 const sample = renderSleepModeDigest(SAMPLE_REPORT, {
5461 username: user?.username || "you",
5562 });
63
64 // Viewer is treated as "Inactive" by default — the real status lives on
65 // the per-user row in /settings. The card is a marketing preview only;
66 // signed-in visitors see a personalised "Inactive — turn it on" CTA.
67 const isActive = false;
68
5669 return c.html(
5770 <Layout title="Sleep Mode — gluecron" user={user}>
5871 <style dangerouslySetInnerHTML={{ __html: pageCss }} />
59 <div class="sm-root">
60 <header class="sm-hero">
61 <div class="eyebrow">Sleep Mode</div>
62 <h1 class="display sm-hero-title">
63 Toggle Sleep Mode. Walk away.{" "}
64 <span class="gradient-text">Wake up to a digest.</span>
65 </h1>
66 <p class="sm-hero-sub">
67 Claude keeps your repos shipping while you sleep &mdash;
68 auto-merging green PRs, building features from{" "}
69 <code>ai:build</code> issues, reviewing code, and quietly fixing
70 the gates that fail. Sleep Mode emails you the highlights at the
71 UTC hour you pick.
72 </p>
73 <div class="sm-hero-cta">
74 <a href="/settings" class="btn btn-primary btn-lg">
75 Enable Sleep Mode in Settings &rarr;
76 </a>
77 <a href="/settings/sleep-mode/preview" class="btn btn-ghost btn-lg">
78 Preview your digest
79 </a>
72 <div class="sleep-wrap">
73 <section class="sleep-hero">
74 <div class="sleep-hero-orb" aria-hidden="true" />
75 <div class="sleep-hero-inner">
76 <div class="sleep-eyebrow">
77 <span class="sleep-eyebrow-pill" aria-hidden="true">
78 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
79 <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
80 </svg>
81 </span>
82 Sleep Mode · overnight autopilot
83 </div>
84 <h1 class="sleep-title">
85 Toggle Sleep Mode. Walk away.{" "}
86 <span class="sleep-title-grad">Wake up to a digest.</span>
87 </h1>
88 <p class="sleep-sub">
89 Claude keeps your repos shipping while you sleep — auto-merging
90 green PRs, building features from <code>ai:build</code> issues,
91 reviewing code, and quietly fixing the gates that fail. One
92 email lands at the UTC hour you pick.
93 </p>
94 <div class="sleep-hero-cta">
95 <a href="/settings" class="sleep-btn sleep-btn-primary">
96 Enable Sleep Mode in Settings <span aria-hidden="true"></span>
97 </a>
98 <a
99 href="/settings/sleep-mode/preview"
100 class="sleep-btn"
101 >
102 Preview your digest
103 </a>
104 </div>
80105 </div>
81 </header>
106 </section>
82107
83 <section class="sm-section">
84 <div class="section-header">
85 <div class="eyebrow">How it works</div>
86 <h2>Three steps. Then forget about it.</h2>
87 </div>
88 <div class="sm-steps">
89 <div class="sm-step">
90 <div class="sm-step-num">1</div>
91 <h3>Flip the toggle</h3>
92 <p>
93 A single checkbox in <a href="/settings">/settings</a>. Pick
94 the UTC hour you want the digest to land &mdash; default is
95 9 AM.
108 {/* ─── Status card — Active / Inactive ─── */}
109 <section
110 class={"sleep-status-card " + (isActive ? "is-active" : "is-inactive")}
111 aria-labelledby="sleep-status-title"
112 >
113 <div class="sleep-status-left">
114 <span class="sleep-status-dot" aria-hidden="true" />
115 <div>
116 <p class="sleep-status-title" id="sleep-status-title">
117 Sleep Mode is{" "}
118 <strong>{isActive ? "Active" : "Inactive"}</strong>
119 {user ? <> for {user.username}</> : null}
96120 </p>
97 </div>
98 <div class="sm-step">
99 <div class="sm-step-num">2</div>
100 <h3>Claude works the night shift</h3>
101 <p>
102 The autopilot sweeps every 5 minutes. Green PRs get
103 auto-merged. <code>ai:build</code> issues become PRs. Gate
104 failures get auto-repaired. Security findings get patched.
121 <p class="sleep-status-sub">
122 {isActive
123 ? "The autopilot will email your digest at the next configured hour."
124 : user
125 ? "Turn it on in Settings and pick a UTC hour. Defaults are safe."
126 : "Sign in to flip the toggle for your account."}
105127 </p>
106128 </div>
107 <div class="sm-step">
108 <div class="sm-step-num">3</div>
109 <h3>Wake up to a digest</h3>
110 <p>
111 One email. Subject line tells you everything: "while you
112 slept, Claude shipped <em>N</em> things". Headlines, links,
113 and an estimate of hours saved.
129 </div>
130 <div class="sleep-status-actions">
131 {user ? (
132 <a href="/settings#sleep-mode" class="sleep-btn sleep-btn-primary">
133 {isActive ? "Manage" : "Turn on"}
134 </a>
135 ) : (
136 <a href="/login?next=/settings" class="sleep-btn sleep-btn-primary">
137 Sign in
138 </a>
139 )}
140 </div>
141 </section>
142
143 {/* ─── How it works — three steps ─── */}
144 <section class="sleep-section" aria-labelledby="sleep-steps-h">
145 <header class="sleep-section-head">
146 <div>
147 <p class="sleep-section-eyebrow">How it works</p>
148 <h2 class="sleep-section-title" id="sleep-steps-h">
149 Three steps. Then forget about it.
150 </h2>
151 </div>
152 </header>
153 <div class="sleep-section-body">
154 <div class="sleep-steps">
155 <article class="sleep-step">
156 <div class="sleep-step-num">1</div>
157 <h3 class="sleep-step-title">Flip the toggle</h3>
158 <p class="sleep-step-body">
159 A single checkbox in <a href="/settings">/settings</a>.
160 Pick the UTC hour you want the digest to land — default
161 is 9 AM.
162 </p>
163 </article>
164 <article class="sleep-step">
165 <div class="sleep-step-num">2</div>
166 <h3 class="sleep-step-title">Claude works the night shift</h3>
167 <p class="sleep-step-body">
168 The autopilot sweeps every 5 minutes. Green PRs get
169 auto-merged. <code>ai:build</code> issues become PRs.
170 Gate failures get auto-repaired. Security findings get
171 patched.
172 </p>
173 </article>
174 <article class="sleep-step">
175 <div class="sleep-step-num">3</div>
176 <h3 class="sleep-step-title">Wake up to a digest</h3>
177 <p class="sleep-step-body">
178 One email. Subject line tells you everything: "while you
179 slept, Claude shipped <em>N</em> things". Headlines,
180 links, and an estimate of hours saved.
181 </p>
182 </article>
183 </div>
184 </div>
185 </section>
186
187 {/* ─── Threshold form (preview) ─── */}
188 <section class="sleep-section" aria-labelledby="sleep-thresholds-h">
189 <header class="sleep-section-head">
190 <div>
191 <p class="sleep-section-eyebrow">Thresholds</p>
192 <h2 class="sleep-section-title" id="sleep-thresholds-h">
193 Conservative defaults you can tighten.
194 </h2>
195 <p class="sleep-section-sub">
196 Preview of the form rendered in <code>/settings</code>. Mutations
197 live there — this card is read-only.
114198 </p>
115199 </div>
200 </header>
201 <div class="sleep-section-body">
202 <div class="sleep-form" aria-disabled="true">
203 <div class="sleep-field">
204 <label class="sleep-field-label" for="sleep-hour">
205 Digest delivery hour <span class="sleep-field-hint">UTC, 0–23</span>
206 </label>
207 <input
208 id="sleep-hour"
209 class="sleep-input"
210 type="number"
211 min={0}
212 max={23}
213 value={9}
214 disabled
215 />
216 </div>
217 <div class="sleep-field">
218 <label class="sleep-field-label" for="sleep-max-merges">
219 Max auto-merges per night <span class="sleep-field-hint">soft cap</span>
220 </label>
221 <input
222 id="sleep-max-merges"
223 class="sleep-input"
224 type="number"
225 min={1}
226 max={500}
227 value={25}
228 disabled
229 />
230 </div>
231 <div class="sleep-field">
232 <label class="sleep-field-label" for="sleep-max-builds">
233 Max AI builds per night <span class="sleep-field-hint">issue → PR</span>
234 </label>
235 <input
236 id="sleep-max-builds"
237 class="sleep-input"
238 type="number"
239 min={0}
240 max={100}
241 value={10}
242 disabled
243 />
244 </div>
245 <div class="sleep-field sleep-field-wide">
246 <label class="sleep-field-label" for="sleep-min-checks">
247 Required green checks before auto-merge
248 </label>
249 <input
250 id="sleep-min-checks"
251 class="sleep-input"
252 type="text"
253 value="ci,gate,build"
254 disabled
255 />
256 <p class="sleep-field-help">
257 Comma-separated check names. PRs are only merged once
258 every listed check is green.
259 </p>
260 </div>
261 </div>
116262 </div>
263 <footer class="sleep-section-foot">
264 <span class="sleep-foot-hint">
265 Edit on <a href="/settings#sleep-mode">/settings</a>.
266 </span>
267 </footer>
117268 </section>
118269
119 <section class="sm-section">
120 <div class="section-header">
121 <div class="eyebrow">Sample digest</div>
122 <h2>Here&rsquo;s what lands in your inbox.</h2>
123 <p>
124 A real Sleep Mode digest, rendered from a synthetic report so
125 you can see the shape before you turn it on.
126 </p>
270 {/* ─── Excluded repos ─── */}
271 <section class="sleep-section" aria-labelledby="sleep-excluded-h">
272 <header class="sleep-section-head">
273 <div>
274 <p class="sleep-section-eyebrow">Excluded repos</p>
275 <h2 class="sleep-section-title" id="sleep-excluded-h">
276 Skip the riskiest projects.
277 </h2>
278 <p class="sleep-section-sub">
279 Sleep Mode will never touch a repo on this list. Add the
280 ones with manual-only release workflows.
281 </p>
282 </div>
283 </header>
284 <div class="sleep-section-body">
285 <div class="sleep-empty">
286 <div class="sleep-empty-orb" aria-hidden="true" />
287 <div class="sleep-empty-inner">
288 <p class="sleep-empty-title">No excluded repos yet.</p>
289 <p class="sleep-empty-sub">
290 When Sleep Mode is on, every repo you own is eligible.
291 Add one here to opt it out. Manage the list from{" "}
292 <a href="/settings#sleep-mode-excluded">Settings</a>.
293 </p>
294 </div>
295 </div>
127296 </div>
128 <div class="sm-sample">
129 <div class="sm-sample-frame">
130 <div class="sm-sample-meta">
131 <span class="sm-sample-from">no-reply@gluecron.app</span>
132 <span class="sm-sample-subject">{sample.subject}</span>
297 </section>
298
299 {/* ─── Sample digest ─── */}
300 <section class="sleep-section" aria-labelledby="sleep-sample-h">
301 <header class="sleep-section-head">
302 <div>
303 <p class="sleep-section-eyebrow">Sample digest</p>
304 <h2 class="sleep-section-title" id="sleep-sample-h">
305 Here's what lands in your inbox.
306 </h2>
307 <p class="sleep-section-sub">
308 A real Sleep Mode digest, rendered from a synthetic report
309 so you can see the shape before you turn it on.
310 </p>
311 </div>
312 </header>
313 <div class="sleep-section-body">
314 <div class="sleep-sample">
315 <div class="sleep-sample-frame">
316 <div class="sleep-sample-meta">
317 <span class="sleep-sample-from">no-reply@gluecron.app</span>
318 <span class="sleep-sample-subject">{sample.subject}</span>
319 </div>
320 <div class="sleep-sample-body">{raw(sample.html)}</div>
133321 </div>
134 <div class="sm-sample-body">{raw(sample.html)}</div>
135322 </div>
136323 </div>
137324 </section>
138325
139 <section class="sm-section sm-cta-section">
140 <h2>Ready to walk away?</h2>
141 <p>
142 Sleep Mode is on-by-default safe &mdash; it can&rsquo;t merge
143 anything that wouldn&rsquo;t pass your branch protection rules.
144 Turn it on, sleep well.
326 <section class="sleep-cta">
327 <h2 class="sleep-cta-title">Ready to walk away?</h2>
328 <p class="sleep-cta-sub">
329 Sleep Mode is on-by-default safe — it can't merge anything
330 that wouldn't pass your branch protection rules. Turn it on,
331 sleep well.
145332 </p>
146 <a href="/settings" class="btn btn-primary btn-lg">
147 Enable Sleep Mode &rarr;
333 <a href="/settings" class="sleep-btn sleep-btn-primary sleep-btn-lg">
334 Enable Sleep Mode <span aria-hidden="true"></span>
148335 </a>
149336 </section>
150337 </div>
153340});
154341
155342const pageCss = `
156.sm-root { max-width: 1080px; margin: 0 auto; padding: 48px 24px 80px; }
157.sm-hero { text-align: center; padding: 32px 0 48px; }
158.sm-hero-title { font-size: clamp(32px, 5vw, 56px); line-height: 1.1; margin: 16px 0 20px; }
159.sm-hero-sub { max-width: 640px; margin: 0 auto; color: var(--text-muted); font-size: 17px; line-height: 1.6; }
160.sm-hero-cta { display: flex; gap: 12px; justify-content: center; flex-wrap: wrap; margin-top: 32px; }
161.sm-section { margin: 64px 0; }
162.sm-steps {
163 display: grid;
164 grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
165 gap: 24px;
166 margin-top: 32px;
167}
168.sm-step {
169 background: var(--accent-gradient-faint);
170 border: 1px solid var(--border);
171 border-radius: 12px;
172 padding: 24px;
173}
174.sm-step h3 { margin: 16px 0 8px; font-size: 18px; }
175.sm-step p { color: var(--text-muted); line-height: 1.55; font-size: 14px; }
176.sm-step-num {
177 display: inline-flex;
178 align-items: center;
179 justify-content: center;
180 width: 36px;
181 height: 36px;
182 border-radius: 9999px;
183 background: var(--accent-gradient);
184 color: #fff;
185 font-weight: 700;
186 font-size: 15px;
187}
188.sm-sample { margin-top: 24px; display: flex; justify-content: center; }
189.sm-sample-frame {
190 background: #fff;
191 color: #111;
192 border-radius: 12px;
193 width: 100%;
194 max-width: 720px;
195 box-shadow: 0 16px 40px rgba(0,0,0,0.25);
196 overflow: hidden;
197 border: 1px solid var(--border);
198}
199.sm-sample-meta {
200 background: #f6f7fb;
201 padding: 12px 20px;
202 display: flex;
203 flex-direction: column;
204 gap: 4px;
205 border-bottom: 1px solid #e6e7ed;
206 font-size: 13px;
207 color: #4a4d59;
208}
209.sm-sample-from { font-size: 12px; color: #8a8e9c; }
210.sm-sample-subject { font-weight: 600; color: #0f1019; }
211.sm-sample-body { background: #fff; }
212.sm-sample-body > * { /* Sample digest html supplies its own padded body. */ }
213.sm-cta-section { text-align: center; padding: 48px 24px; background: var(--accent-gradient-soft); border-radius: 16px; }
214.sm-cta-section h2 { font-size: 28px; margin-bottom: 12px; }
215.sm-cta-section p { max-width: 520px; margin: 0 auto 24px; color: var(--text-muted); }
343 .sleep-wrap { max-width: 1080px; margin: 0 auto; padding: var(--space-6) var(--space-4); }
344
345 /* ─── Hero ─── */
346 .sleep-hero {
347 position: relative;
348 margin-bottom: var(--space-5);
349 padding: var(--space-6) var(--space-6);
350 background: var(--bg-elevated);
351 border: 1px solid var(--border);
352 border-radius: 18px;
353 overflow: hidden;
354 }
355 .sleep-hero::before {
356 content: '';
357 position: absolute;
358 top: 0; left: 0; right: 0;
359 height: 2px;
360 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
361 opacity: 0.7;
362 pointer-events: none;
363 }
364 .sleep-hero-orb {
365 position: absolute;
366 inset: -25% -10% auto auto;
367 width: 460px; height: 460px;
368 background: radial-gradient(circle, rgba(140,109,255,0.22), rgba(54,197,214,0.10) 45%, transparent 70%);
369 filter: blur(80px);
370 opacity: 0.7;
371 pointer-events: none;
372 z-index: 0;
373 }
374 .sleep-hero-inner { position: relative; z-index: 1; max-width: 720px; }
375 .sleep-eyebrow {
376 font-size: 12px;
377 color: var(--text-muted);
378 margin-bottom: var(--space-2);
379 letter-spacing: 0.02em;
380 display: inline-flex;
381 align-items: center;
382 gap: 8px;
383 }
384 .sleep-eyebrow-pill {
385 display: inline-flex;
386 align-items: center;
387 justify-content: center;
388 width: 18px; height: 18px;
389 border-radius: 6px;
390 background: rgba(140,109,255,0.14);
391 color: #b69dff;
392 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
393 }
394 .sleep-title {
395 font-size: clamp(32px, 5vw, 52px);
396 font-family: var(--font-display);
397 font-weight: 800;
398 letter-spacing: -0.028em;
399 line-height: 1.05;
400 margin: 0 0 var(--space-3);
401 color: var(--text-strong);
402 }
403 .sleep-title-grad {
404 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
405 -webkit-background-clip: text;
406 background-clip: text;
407 -webkit-text-fill-color: transparent;
408 color: transparent;
409 }
410 .sleep-sub {
411 font-size: 16px;
412 color: var(--text-muted);
413 margin: 0 0 var(--space-4);
414 line-height: 1.55;
415 max-width: 640px;
416 }
417 .sleep-sub code {
418 font-family: var(--font-mono);
419 font-size: 13px;
420 background: var(--bg-tertiary);
421 padding: 1px 5px;
422 border-radius: 4px;
423 }
424 .sleep-hero-cta { display: flex; gap: 10px; flex-wrap: wrap; }
425
426 /* ─── Buttons ─── */
427 .sleep-btn {
428 appearance: none;
429 border: 1px solid var(--border-strong);
430 background: var(--bg-secondary);
431 color: var(--text);
432 padding: 10px 16px;
433 border-radius: 10px;
434 font-family: inherit;
435 font-size: 14px;
436 font-weight: 500;
437 cursor: pointer;
438 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease;
439 text-decoration: none;
440 display: inline-flex; align-items: center; gap: 8px;
441 }
442 .sleep-btn:hover {
443 border-color: var(--border-focus);
444 background: rgba(255,255,255,0.03);
445 transform: translateY(-1px);
446 text-decoration: none;
447 }
448 .sleep-btn-primary {
449 border-color: rgba(140,109,255,0.45);
450 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
451 color: var(--text-strong);
452 }
453 .sleep-btn-primary:hover {
454 border-color: rgba(140,109,255,0.65);
455 background: linear-gradient(135deg, rgba(140,109,255,0.28), rgba(54,197,214,0.20));
456 }
457 .sleep-btn-lg { padding: 12px 20px; font-size: 15px; }
458
459 /* ─── Status card ─── */
460 .sleep-status-card {
461 margin-bottom: var(--space-5);
462 padding: var(--space-4) var(--space-5);
463 background: var(--bg-elevated);
464 border: 1px solid var(--border);
465 border-radius: 14px;
466 display: flex;
467 align-items: center;
468 justify-content: space-between;
469 gap: var(--space-4);
470 flex-wrap: wrap;
471 }
472 .sleep-status-left {
473 display: flex;
474 align-items: center;
475 gap: 14px;
476 flex: 1;
477 min-width: 240px;
478 }
479 .sleep-status-dot {
480 width: 12px; height: 12px;
481 border-radius: 50%;
482 flex-shrink: 0;
483 background: var(--text-faint);
484 }
485 .sleep-status-card.is-active .sleep-status-dot {
486 background: #3fb950;
487 box-shadow: 0 0 0 4px rgba(63,185,80,0.18);
488 }
489 .sleep-status-card.is-inactive .sleep-status-dot {
490 background: #6e7681;
491 box-shadow: 0 0 0 4px rgba(110,118,129,0.14);
492 }
493 .sleep-status-title {
494 margin: 0;
495 font-size: 14.5px;
496 color: var(--text-strong);
497 font-weight: 600;
498 }
499 .sleep-status-title strong {
500 color: var(--text-strong);
501 font-weight: 700;
502 }
503 .sleep-status-card.is-active .sleep-status-title strong { color: #6ee7b7; }
504 .sleep-status-card.is-inactive .sleep-status-title strong { color: var(--text-muted); }
505 .sleep-status-sub {
506 margin: 4px 0 0;
507 font-size: 12.5px;
508 color: var(--text-muted);
509 line-height: 1.5;
510 }
511 .sleep-status-actions { flex-shrink: 0; }
512
513 /* ─── Section cards ─── */
514 .sleep-section {
515 margin-bottom: var(--space-5);
516 background: var(--bg-elevated);
517 border: 1px solid var(--border);
518 border-radius: 14px;
519 overflow: hidden;
520 }
521 .sleep-section-head {
522 padding: var(--space-4) var(--space-5);
523 border-bottom: 1px solid var(--border);
524 }
525 .sleep-section-eyebrow {
526 font-size: 11px;
527 font-weight: 600;
528 letter-spacing: 0.08em;
529 text-transform: uppercase;
530 color: var(--text-faint);
531 margin: 0 0 6px;
532 }
533 .sleep-section-title {
534 margin: 0;
535 font-family: var(--font-display);
536 font-size: 17px;
537 font-weight: 700;
538 letter-spacing: -0.018em;
539 color: var(--text-strong);
540 }
541 .sleep-section-sub {
542 margin: 6px 0 0;
543 font-size: 12.5px;
544 color: var(--text-muted);
545 line-height: 1.5;
546 }
547 .sleep-section-sub code {
548 font-family: var(--font-mono);
549 font-size: 11.5px;
550 background: var(--bg-tertiary);
551 padding: 1px 5px;
552 border-radius: 4px;
553 }
554 .sleep-section-body { padding: var(--space-5); }
555 .sleep-section-foot {
556 padding: var(--space-3) var(--space-5);
557 border-top: 1px solid var(--border);
558 background: rgba(255,255,255,0.012);
559 font-size: 12.5px;
560 color: var(--text-muted);
561 }
562 .sleep-foot-hint a { color: var(--accent); text-decoration: none; }
563 .sleep-foot-hint a:hover { text-decoration: underline; }
564
565 /* ─── Steps ─── */
566 .sleep-steps {
567 display: grid;
568 grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
569 gap: var(--space-3);
570 }
571 .sleep-step {
572 position: relative;
573 padding: var(--space-4);
574 background: var(--bg-secondary);
575 border: 1px solid var(--border-subtle);
576 border-radius: 12px;
577 display: flex;
578 flex-direction: column;
579 gap: 10px;
580 transition: border-color 150ms ease, transform 150ms ease;
581 }
582 .sleep-step:hover {
583 border-color: var(--border-strong);
584 transform: translateY(-1px);
585 }
586 .sleep-step-num {
587 display: inline-flex;
588 align-items: center;
589 justify-content: center;
590 width: 30px; height: 30px;
591 border-radius: 50%;
592 background: linear-gradient(135deg, rgba(140,109,255,0.22), rgba(54,197,214,0.16));
593 color: #c5b3ff;
594 border: 1px solid rgba(140,109,255,0.40);
595 font-family: var(--font-display);
596 font-weight: 700;
597 font-size: 14px;
598 }
599 .sleep-step-title {
600 margin: 0;
601 font-family: var(--font-display);
602 font-size: 15px;
603 font-weight: 700;
604 color: var(--text-strong);
605 letter-spacing: -0.012em;
606 }
607 .sleep-step-body {
608 margin: 0;
609 font-size: 13.5px;
610 color: var(--text-muted);
611 line-height: 1.55;
612 }
613 .sleep-step-body code {
614 font-family: var(--font-mono);
615 font-size: 12px;
616 background: var(--bg-tertiary);
617 padding: 1px 5px;
618 border-radius: 4px;
619 }
620 .sleep-step-body a { color: var(--accent); text-decoration: none; }
621 .sleep-step-body a:hover { text-decoration: underline; }
622
623 /* ─── Form (preview) ─── */
624 .sleep-form {
625 display: grid;
626 grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
627 gap: var(--space-4);
628 }
629 .sleep-field { display: flex; flex-direction: column; gap: 6px; }
630 .sleep-field-wide { grid-column: 1 / -1; }
631 .sleep-field-label {
632 font-family: var(--font-mono);
633 font-size: 12.5px;
634 font-weight: 600;
635 color: var(--text-strong);
636 letter-spacing: -0.005em;
637 display: flex;
638 align-items: baseline;
639 justify-content: space-between;
640 gap: 8px;
641 }
642 .sleep-field-hint {
643 font-family: var(--font-sans, inherit);
644 font-size: 11px;
645 color: var(--text-faint);
646 text-transform: none;
647 font-weight: 500;
648 letter-spacing: 0.02em;
649 }
650 .sleep-input {
651 width: 100%;
652 padding: 9px 12px;
653 font-size: 13.5px;
654 color: var(--text);
655 background: var(--bg);
656 border: 1px solid var(--border-strong);
657 border-radius: 8px;
658 outline: none;
659 font-family: var(--font-mono);
660 box-sizing: border-box;
661 transition: border-color 120ms ease, box-shadow 120ms ease, opacity 120ms ease;
662 }
663 .sleep-input:disabled {
664 opacity: 0.75;
665 cursor: not-allowed;
666 }
667 .sleep-field-help {
668 margin: 0;
669 font-size: 11.5px;
670 color: var(--text-muted);
671 line-height: 1.45;
672 }
673
674 /* ─── Empty state ─── */
675 .sleep-empty {
676 position: relative;
677 padding: var(--space-6) var(--space-5);
678 border: 1px dashed var(--border-strong);
679 border-radius: 14px;
680 background: rgba(255,255,255,0.02);
681 text-align: center;
682 overflow: hidden;
683 }
684 .sleep-empty-orb {
685 position: absolute;
686 inset: -40% -10% auto auto;
687 width: 320px; height: 320px;
688 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
689 filter: blur(70px);
690 opacity: 0.55;
691 pointer-events: none;
692 z-index: 0;
693 }
694 .sleep-empty-inner { position: relative; z-index: 1; }
695 .sleep-empty-title {
696 margin: 0 0 6px;
697 font-family: var(--font-display);
698 font-size: 16px;
699 font-weight: 700;
700 color: var(--text-strong);
701 letter-spacing: -0.012em;
702 }
703 .sleep-empty-sub {
704 margin: 0 auto;
705 max-width: 460px;
706 font-size: 13px;
707 color: var(--text-muted);
708 line-height: 1.5;
709 }
710 .sleep-empty-sub a { color: var(--accent); text-decoration: none; }
711 .sleep-empty-sub a:hover { text-decoration: underline; }
712
713 /* ─── Sample digest ─── */
714 .sleep-sample { display: flex; justify-content: center; }
715 .sleep-sample-frame {
716 background: #fff;
717 color: #111;
718 border-radius: 12px;
719 width: 100%;
720 max-width: 720px;
721 box-shadow: 0 16px 40px rgba(0,0,0,0.25);
722 overflow: hidden;
723 border: 1px solid var(--border);
724 }
725 .sleep-sample-meta {
726 background: #f6f7fb;
727 padding: 12px 20px;
728 display: flex;
729 flex-direction: column;
730 gap: 4px;
731 border-bottom: 1px solid #e6e7ed;
732 font-size: 13px;
733 color: #4a4d59;
734 }
735 .sleep-sample-from { font-size: 12px; color: #8a8e9c; }
736 .sleep-sample-subject { font-weight: 600; color: #0f1019; }
737 .sleep-sample-body { background: #fff; }
738
739 /* ─── CTA card ─── */
740 .sleep-cta {
741 margin-top: var(--space-6);
742 padding: var(--space-6) var(--space-5);
743 background: linear-gradient(135deg, rgba(140,109,255,0.14), rgba(54,197,214,0.08));
744 border: 1px solid rgba(140,109,255,0.30);
745 border-radius: 16px;
746 text-align: center;
747 }
748 .sleep-cta-title {
749 margin: 0 0 8px;
750 font-family: var(--font-display);
751 font-size: 24px;
752 font-weight: 800;
753 color: var(--text-strong);
754 letter-spacing: -0.022em;
755 }
756 .sleep-cta-sub {
757 max-width: 540px;
758 margin: 0 auto var(--space-4);
759 font-size: 14px;
760 color: var(--text-muted);
761 line-height: 1.55;
762 }
763
764 @media (max-width: 640px) {
765 .sleep-status-card { flex-direction: column; align-items: flex-start; }
766 .sleep-status-actions { width: 100%; }
767 .sleep-status-actions .sleep-btn { width: 100%; justify-content: center; }
768 }
216769`;
217770
218771export default sleepMode;
Modifiedsrc/routes/workflow-secrets.tsx+620−149View fileUnifiedSplit
1010 * The UI never sees plaintext values after upsert — the `listRepoSecrets`
1111 * helper in `../lib/workflow-secrets` returns metadata only (id, name,
1212 * createdAt, createdBy). This file's sole job is to render and mutate.
13 *
14 * 2026 polish: scoped `.wsec-` CSS, hero with eyebrow + gradient title,
15 * masked-value list with mono names + last-used timestamps + revoke
16 * actions, and a separate add-new card. No shared file touches.
1317 */
1418
1519import { Hono } from "hono";
16import { eq, inArray } from "drizzle-orm";
20import { inArray } from "drizzle-orm";
1721import { db } from "../db";
1822import { users } from "../db/schema";
1923import { Layout } from "../views/layout";
2125import { softAuth, requireAuth } from "../middleware/auth";
2226import type { AuthEnv } from "../middleware/auth";
2327import { requireRepoAccess } from "../middleware/repo-access";
24import {
25 Container,
26 Alert,
27 EmptyState,
28 Button,
29 Text,
30 formatRelative,
31} from "../views/ui";
28import { formatRelative } from "../views/ui";
3229import {
3330 listRepoSecrets,
3431 upsertRepoSecret,
6158 ) => (
6259 <a
6360 href={href}
64 style={
65 "padding: 6px 12px; border-radius: 6px; text-decoration: none; " +
66 (active === key
67 ? "background: var(--bg-secondary); color: var(--text); font-weight: 600"
68 : "color: var(--text-muted)")
61 class={
62 "wsec-subnav-link" + (active === key ? " is-active" : "")
6963 }
7064 >
7165 {label}
7266 </a>
7367 );
7468 return (
75 <div
76 style="display: flex; gap: 4px; margin: 12px 0 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border)"
77 >
69 <nav class="wsec-subnav" aria-label="Repository settings">
7870 {link(`/${owner}/${repo}/settings`, "General", "general")}
7971 {link(
8072 `/${owner}/${repo}/settings/collaborators`,
9183 "Secrets",
9284 "secrets"
9385 )}
94 </div>
86 </nav>
9587 );
9688}
9789
90/**
91 * Best-effort masked preview of a secret. We never have the plaintext on
92 * disk, so this is a stable visual placeholder (always 12 bullets). The
93 * dot count is deliberately constant so the list doesn't leak length.
94 */
95const MASKED_PLACEHOLDER = "••••••••••••";
96
9897// ─── GET: List + add form ───────────────────────────────────────────────────
9998
10099workflowSecretsRoutes.get(
134133 <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}>
135134 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
136135 <RepoNav owner={ownerName} repo={repoName} active="settings" />
137 <Container maxWidth={760}>
136 <style dangerouslySetInnerHTML={{ __html: wsecStyles }} />
137 <div class="wsec-wrap">
138138 <SettingsSubNav
139139 owner={ownerName}
140140 repo={repoName}
141141 active="secrets"
142142 />
143143
144 <h2 style="margin-bottom: 8px">Workflow secrets</h2>
145 <Text size={14} muted style="display:block;margin-bottom:20px">
146 Secrets are encrypted at rest and only decrypted at workflow
147 runtime. They are never printed to logs. Reference them in YAML
148 as{" "}
149 <code style="font-family: var(--font-mono); background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px">
150 {"${{ secrets.NAME }}"}
151 </code>
152 . Values are write-only — after saving, only the name is
153 visible.
154 </Text>
144 {/* ─── Hero ─── */}
145 <section class="wsec-hero">
146 <div class="wsec-hero-orb" aria-hidden="true" />
147 <div class="wsec-hero-inner">
148 <div class="wsec-eyebrow">
149 <span class="wsec-eyebrow-pill" aria-hidden="true">
150 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
151 <rect x="3" y="11" width="18" height="11" rx="2" />
152 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
153 </svg>
154 </span>
155 Workflow secrets · <strong>{ownerName}/{repoName}</strong>
156 </div>
157 <h1 class="wsec-title">
158 Secrets, <span class="wsec-title-grad">encrypted at rest.</span>
159 </h1>
160 <p class="wsec-sub">
161 Reference them in YAML as{" "}
162 <code>{"${{ secrets.NAME }}"}</code>. Values are write-only —
163 after saving, only the name is visible. They're never printed
164 to workflow logs.
165 </p>
166 </div>
167 </section>
155168
169 {/* ─── Flash banners ─── */}
156170 {added && (
157 <Alert variant="success">
171 <div class="wsec-banner is-ok">
172 <span class="wsec-banner-dot" aria-hidden="true" />
158173 Secret <code>{decodeURIComponent(added)}</code> saved.
159 </Alert>
174 </div>
175 )}
176 {deleted && (
177 <div class="wsec-banner is-ok">
178 <span class="wsec-banner-dot" aria-hidden="true" />
179 Secret deleted.
180 </div>
160181 )}
161 {deleted && <Alert variant="success">Secret deleted.</Alert>}
162182 {error && (
163 <Alert variant="error">{decodeURIComponent(error)}</Alert>
183 <div class="wsec-banner is-error">
184 <span class="wsec-banner-dot" aria-hidden="true" />
185 {decodeURIComponent(error)}
186 </div>
164187 )}
165188 {loadError && (
166 <Alert variant="error">
189 <div class="wsec-banner is-error">
190 <span class="wsec-banner-dot" aria-hidden="true" />
167191 Could not load secrets: {loadError}
168 </Alert>
192 </div>
169193 )}
170194
171 <div class="panel" style="margin-top: 16px; padding: 0; overflow: hidden">
172 {secrets.length === 0 ? (
173 <div style="padding: 24px">
174 <EmptyState>
175 <Text muted>No secrets yet.</Text>
176 </EmptyState>
195 {/* ─── Secrets list ─── */}
196 <section class="wsec-section" aria-labelledby="wsec-list-h">
197 <header class="wsec-section-head">
198 <div>
199 <h2 class="wsec-section-title" id="wsec-list-h">
200 Stored secrets
201 </h2>
202 <p class="wsec-section-sub">
203 {secrets.length === 0
204 ? "Add your first secret below."
205 : `${secrets.length} secret${secrets.length === 1 ? "" : "s"} available to workflows in this repo.`}
206 </p>
177207 </div>
178 ) : (
179 <table
180 class="file-table"
181 style="width: 100%; border-collapse: collapse"
182 >
183 <thead>
184 <tr style="background: var(--bg-secondary); text-align: left">
185 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
186 Name
187 </th>
188 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
189 Added by
190 </th>
191 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
192 Added
193 </th>
194 <th style="padding: 10px 14px" />
195 </tr>
196 </thead>
197 <tbody>
208 <span class="wsec-count-pill">
209 {secrets.length} / unlimited
210 </span>
211 </header>
212 <div class="wsec-section-body">
213 {secrets.length === 0 ? (
214 <div class="wsec-empty">
215 <div class="wsec-empty-orb" aria-hidden="true" />
216 <div class="wsec-empty-inner">
217 <p class="wsec-empty-title">No secrets yet.</p>
218 <p class="wsec-empty-sub">
219 Add an encrypted secret below — names like{" "}
220 <code>DEPLOY_TOKEN</code> or <code>STRIPE_KEY</code>{" "}
221 become available to every workflow step in this repo.
222 </p>
223 </div>
224 </div>
225 ) : (
226 <ul class="wsec-list">
198227 {secrets.map((s) => {
199228 const creator = s.createdBy
200229 ? creatorMap.get(s.createdBy)
201230 : null;
231 const created = s.createdAt
232 ? formatRelative(s.createdAt)
233 : "—";
202234 return (
203 <tr style="border-top: 1px solid var(--border)">
204 <td style="padding: 10px 14px">
205 <code
206 style="font-family: var(--font-mono); font-size: 13px"
207 >
208 {s.name}
209 </code>
210 </td>
211 <td style="padding: 10px 14px; font-size: 13px">
212 {creator ? (
213 <a href={`/${creator}`}>{creator}</a>
214 ) : (
215 <span style="color: var(--text-muted)">
216 unknown
235 <li class="wsec-row">
236 <div class="wsec-row-main">
237 <div class="wsec-row-name-row">
238 <code class="wsec-row-name">{s.name}</code>
239 <span class="wsec-row-masked" aria-label="value hidden">
240 {MASKED_PLACEHOLDER}
217241 </span>
218 )}
219 </td>
220 <td
221 style="padding: 10px 14px; font-size: 13px; color: var(--text-muted)"
222 title={
223 s.createdAt
224 ? new Date(s.createdAt).toISOString()
225 : ""
226 }
227 >
228 {s.createdAt
229 ? formatRelative(s.createdAt)
230 : "—"}
231 </td>
232 <td style="padding: 10px 14px; text-align: right">
242 </div>
243 <div class="wsec-row-meta">
244 <span class="wsec-meta-item">
245 Added {created}
246 </span>
247 <span class="wsec-meta-sep" aria-hidden="true">·</span>
248 <span class="wsec-meta-item">
249 by{" "}
250 {creator ? (
251 <a
252 href={`/${creator}`}
253 class="wsec-meta-link"
254 >
255 {creator}
256 </a>
257 ) : (
258 <span class="wsec-meta-faint">unknown</span>
259 )}
260 </span>
261 </div>
262 </div>
263 <div class="wsec-row-actions">
233264 <form
234265 method="post"
235266 action={`/${ownerName}/${repoName}/settings/secrets/${s.id}/delete`}
236 style="display: inline"
237 onsubmit={`return confirm('Delete secret ${s.name}?')`}
267 onsubmit={`return confirm('Revoke secret ${s.name}? Workflow steps that reference it will fail until you add it again.')`}
238268 >
239 <Button
269 <button
240270 type="submit"
241 variant="danger"
242 size="sm"
271 class="wsec-btn wsec-btn-danger"
243272 >
244 Delete
245 </Button>
273 Revoke
274 </button>
246275 </form>
247 </td>
248 </tr>
276 </div>
277 </li>
249278 );
250279 })}
251 </tbody>
252 </table>
253 )}
254 </div>
255
256 <h3 style="margin-top: 32px; margin-bottom: 4px">
257 Add a new secret
258 </h3>
259 <Text size={13} muted style="display:block;margin-bottom:12px">
260 Names must be uppercase letters, digits, and underscores, and
261 cannot start with a digit. Adding a secret with an existing
262 name replaces the stored value.
263 </Text>
264 <form
265 method="post"
266 action={`/${ownerName}/${repoName}/settings/secrets`}
267 >
268 <div class="form-group">
269 <label for="secret-name">Name</label>
270 <input
271 type="text"
272 id="secret-name"
273 name="name"
274 required
275 pattern="[A-Z_][A-Z0-9_]*"
276 maxlength={MAX_NAME_LEN}
277 placeholder="DEPLOY_TOKEN"
278 autocomplete="off"
279 style="font-family: var(--font-mono)"
280 title="Uppercase letters, digits, and underscores; cannot start with a digit"
281 />
280 </ul>
281 )}
282282 </div>
283 <div class="form-group">
284 <label for="secret-value">Value</label>
285 <textarea
286 id="secret-value"
287 name="value"
288 required
289 rows={4}
290 maxlength={MAX_VALUE_LEN}
291 placeholder="Paste secret value"
292 autocomplete="off"
293 spellcheck={false}
294 style="width: 100%; font-family: var(--font-mono); font-size: 13px"
295 />
283 </section>
284
285 {/* ─── Add new ─── */}
286 <section class="wsec-section" aria-labelledby="wsec-add-h">
287 <header class="wsec-section-head">
288 <div>
289 <h2 class="wsec-section-title" id="wsec-add-h">
290 Add a new secret
291 </h2>
292 <p class="wsec-section-sub">
293 Names must be uppercase letters, digits, and underscores,
294 and cannot start with a digit. Adding a secret with an
295 existing name replaces the stored value.
296 </p>
297 </div>
298 </header>
299 <div class="wsec-section-body">
300 <form
301 method="post"
302 action={`/${ownerName}/${repoName}/settings/secrets`}
303 class="wsec-form"
304 >
305 <div class="wsec-field">
306 <label class="wsec-field-label" for="secret-name">
307 Name
308 </label>
309 <input
310 type="text"
311 id="secret-name"
312 name="name"
313 required
314 pattern="[A-Z_][A-Z0-9_]*"
315 maxlength={MAX_NAME_LEN}
316 placeholder="DEPLOY_TOKEN"
317 autocomplete="off"
318 class="wsec-input wsec-input-mono"
319 title="Uppercase letters, digits, and underscores; cannot start with a digit"
320 />
321 <p class="wsec-field-help">
322 Referenced in YAML as{" "}
323 <code>{"${{ secrets.YOUR_NAME }}"}</code>.
324 </p>
325 </div>
326 <div class="wsec-field">
327 <label class="wsec-field-label" for="secret-value">
328 Value
329 </label>
330 <textarea
331 id="secret-value"
332 name="value"
333 required
334 rows={5}
335 maxlength={MAX_VALUE_LEN}
336 placeholder="Paste secret value"
337 autocomplete="off"
338 spellcheck={false}
339 class="wsec-input wsec-input-mono wsec-textarea"
340 />
341 <p class="wsec-field-help">
342 Encrypted with AES-256-GCM before the row hits the
343 database. We never write it to logs.
344 </p>
345 </div>
346 <div class="wsec-form-actions">
347 <button
348 type="submit"
349 class="wsec-btn wsec-btn-primary"
350 >
351 Add secret
352 </button>
353 </div>
354 </form>
296355 </div>
297 <button type="submit" class="btn btn-primary">
298 Add secret
299 </button>
300 </form>
301 </Container>
356 </section>
357 </div>
302358 </Layout>
303359 );
304360 }
408464 }
409465);
410466
467/* ─────────────────────────────────────────────────────────────────────────
468 * Scoped CSS — every class prefixed `.wsec-` so this surface can't bleed
469 * into other settings pages.
470 * ───────────────────────────────────────────────────────────────────── */
471const wsecStyles = `
472 .wsec-wrap { max-width: 880px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
473
474 /* ─── Sub-nav ─── */
475 .wsec-subnav {
476 display: flex;
477 gap: 4px;
478 margin: 0 0 var(--space-4);
479 padding-bottom: var(--space-3);
480 border-bottom: 1px solid var(--border);
481 flex-wrap: wrap;
482 }
483 .wsec-subnav-link {
484 padding: 6px 12px;
485 border-radius: 8px;
486 text-decoration: none;
487 font-size: 13.5px;
488 color: var(--text-muted);
489 transition: color 120ms ease, background 120ms ease;
490 }
491 .wsec-subnav-link:hover {
492 color: var(--text);
493 text-decoration: none;
494 background: rgba(255,255,255,0.03);
495 }
496 .wsec-subnav-link.is-active {
497 background: var(--bg-secondary);
498 color: var(--text-strong);
499 font-weight: 600;
500 }
501
502 /* ─── Hero ─── */
503 .wsec-hero {
504 position: relative;
505 margin-bottom: var(--space-5);
506 padding: var(--space-5) var(--space-6);
507 background: var(--bg-elevated);
508 border: 1px solid var(--border);
509 border-radius: 16px;
510 overflow: hidden;
511 }
512 .wsec-hero::before {
513 content: '';
514 position: absolute;
515 top: 0; left: 0; right: 0;
516 height: 2px;
517 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
518 opacity: 0.7;
519 pointer-events: none;
520 }
521 .wsec-hero-orb {
522 position: absolute;
523 inset: -20% -10% auto auto;
524 width: 380px; height: 380px;
525 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
526 filter: blur(80px);
527 opacity: 0.65;
528 pointer-events: none;
529 z-index: 0;
530 }
531 .wsec-hero-inner { position: relative; z-index: 1; max-width: 640px; }
532 .wsec-eyebrow {
533 font-size: 12px;
534 color: var(--text-muted);
535 margin-bottom: var(--space-2);
536 letter-spacing: 0.02em;
537 display: inline-flex;
538 align-items: center;
539 gap: 8px;
540 }
541 .wsec-eyebrow strong {
542 color: var(--accent);
543 font-weight: 600;
544 }
545 .wsec-eyebrow-pill {
546 display: inline-flex;
547 align-items: center;
548 justify-content: center;
549 width: 18px; height: 18px;
550 border-radius: 6px;
551 background: rgba(140,109,255,0.14);
552 color: #b69dff;
553 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
554 }
555 .wsec-title {
556 font-size: clamp(26px, 4vw, 36px);
557 font-family: var(--font-display);
558 font-weight: 800;
559 letter-spacing: -0.026em;
560 line-height: 1.05;
561 margin: 0 0 var(--space-2);
562 color: var(--text-strong);
563 }
564 .wsec-title-grad {
565 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
566 -webkit-background-clip: text;
567 background-clip: text;
568 -webkit-text-fill-color: transparent;
569 color: transparent;
570 }
571 .wsec-sub {
572 font-size: 14.5px;
573 color: var(--text-muted);
574 margin: 0;
575 line-height: 1.55;
576 }
577 .wsec-sub code {
578 font-family: var(--font-mono);
579 font-size: 12.5px;
580 background: var(--bg-tertiary);
581 padding: 1px 5px;
582 border-radius: 4px;
583 color: var(--text-strong);
584 }
585
586 /* ─── Banner ─── */
587 .wsec-banner {
588 margin-bottom: var(--space-4);
589 padding: 10px 14px;
590 border-radius: 10px;
591 font-size: 13.5px;
592 border: 1px solid var(--border);
593 background: rgba(255,255,255,0.025);
594 color: var(--text);
595 display: flex;
596 align-items: center;
597 gap: 10px;
598 }
599 .wsec-banner code {
600 font-family: var(--font-mono);
601 font-size: 12.5px;
602 background: rgba(0,0,0,0.18);
603 padding: 1px 6px;
604 border-radius: 4px;
605 }
606 .wsec-banner.is-ok {
607 border-color: rgba(52,211,153,0.40);
608 background: rgba(52,211,153,0.08);
609 color: #bbf7d0;
610 }
611 .wsec-banner.is-error {
612 border-color: rgba(248,113,113,0.40);
613 background: rgba(248,113,113,0.08);
614 color: #fecaca;
615 }
616 .wsec-banner-dot {
617 width: 8px; height: 8px;
618 border-radius: 9999px;
619 background: currentColor;
620 flex-shrink: 0;
621 }
622
623 /* ─── Section cards ─── */
624 .wsec-section {
625 margin-bottom: var(--space-5);
626 background: var(--bg-elevated);
627 border: 1px solid var(--border);
628 border-radius: 14px;
629 overflow: hidden;
630 }
631 .wsec-section-head {
632 padding: var(--space-4) var(--space-5);
633 border-bottom: 1px solid var(--border);
634 display: flex;
635 align-items: flex-start;
636 justify-content: space-between;
637 gap: var(--space-3);
638 flex-wrap: wrap;
639 }
640 .wsec-section-title {
641 margin: 0;
642 font-family: var(--font-display);
643 font-size: 17px;
644 font-weight: 700;
645 letter-spacing: -0.018em;
646 color: var(--text-strong);
647 }
648 .wsec-section-sub {
649 margin: 6px 0 0;
650 font-size: 12.5px;
651 color: var(--text-muted);
652 line-height: 1.5;
653 }
654 .wsec-section-sub code {
655 font-family: var(--font-mono);
656 font-size: 11.5px;
657 background: var(--bg-tertiary);
658 padding: 1px 5px;
659 border-radius: 4px;
660 }
661 .wsec-section-body { padding: 0; }
662
663 .wsec-count-pill {
664 display: inline-flex;
665 align-items: center;
666 padding: 4px 10px;
667 border-radius: 9999px;
668 font-family: var(--font-mono);
669 font-size: 11.5px;
670 font-weight: 600;
671 background: rgba(140,109,255,0.10);
672 color: #c5b3ff;
673 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
674 letter-spacing: 0.02em;
675 }
676
677 /* ─── Secret list rows ─── */
678 .wsec-list {
679 list-style: none;
680 margin: 0;
681 padding: 0;
682 }
683 .wsec-row {
684 display: flex;
685 align-items: center;
686 justify-content: space-between;
687 gap: var(--space-3);
688 padding: var(--space-3) var(--space-5);
689 border-bottom: 1px solid var(--border);
690 transition: background 120ms ease;
691 }
692 .wsec-row:last-child { border-bottom: 0; }
693 .wsec-row:hover { background: rgba(255,255,255,0.018); }
694 .wsec-row-main { flex: 1; min-width: 0; }
695 .wsec-row-name-row {
696 display: flex;
697 align-items: center;
698 gap: 10px;
699 flex-wrap: wrap;
700 }
701 .wsec-row-name {
702 font-family: var(--font-mono);
703 font-size: 13.5px;
704 font-weight: 600;
705 color: var(--text-strong);
706 background: var(--bg-secondary);
707 padding: 3px 8px;
708 border-radius: 6px;
709 border: 1px solid var(--border-subtle);
710 }
711 .wsec-row-masked {
712 font-family: var(--font-mono);
713 font-size: 13px;
714 color: var(--text-faint);
715 letter-spacing: 1px;
716 user-select: none;
717 }
718 .wsec-row-meta {
719 margin-top: 6px;
720 font-size: 12px;
721 color: var(--text-muted);
722 display: flex;
723 align-items: center;
724 gap: 6px;
725 flex-wrap: wrap;
726 }
727 .wsec-meta-sep { color: var(--text-faint); }
728 .wsec-meta-link {
729 color: var(--accent);
730 text-decoration: none;
731 }
732 .wsec-meta-link:hover { text-decoration: underline; }
733 .wsec-meta-faint { color: var(--text-faint); }
734 .wsec-row-actions { flex-shrink: 0; }
735
736 /* ─── Empty state ─── */
737 .wsec-empty {
738 position: relative;
739 margin: var(--space-4) var(--space-5) var(--space-5);
740 padding: var(--space-6) var(--space-5);
741 border: 1px dashed var(--border-strong);
742 border-radius: 14px;
743 background: rgba(255,255,255,0.02);
744 text-align: center;
745 overflow: hidden;
746 }
747 .wsec-empty-orb {
748 position: absolute;
749 inset: -40% -10% auto auto;
750 width: 320px; height: 320px;
751 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
752 filter: blur(70px);
753 opacity: 0.55;
754 pointer-events: none;
755 z-index: 0;
756 }
757 .wsec-empty-inner { position: relative; z-index: 1; }
758 .wsec-empty-title {
759 margin: 0 0 6px;
760 font-family: var(--font-display);
761 font-size: 16px;
762 font-weight: 700;
763 color: var(--text-strong);
764 letter-spacing: -0.012em;
765 }
766 .wsec-empty-sub {
767 margin: 0 auto;
768 max-width: 460px;
769 font-size: 13px;
770 color: var(--text-muted);
771 line-height: 1.5;
772 }
773 .wsec-empty-sub code {
774 font-family: var(--font-mono);
775 font-size: 12px;
776 background: var(--bg-tertiary);
777 padding: 1px 5px;
778 border-radius: 4px;
779 color: var(--text-strong);
780 }
781
782 /* ─── Form ─── */
783 .wsec-form { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
784 .wsec-field { display: flex; flex-direction: column; gap: 6px; }
785 .wsec-field-label {
786 font-family: var(--font-mono);
787 font-size: 12.5px;
788 font-weight: 600;
789 color: var(--text-strong);
790 letter-spacing: -0.005em;
791 }
792 .wsec-input {
793 width: 100%;
794 padding: 9px 12px;
795 font-size: 13.5px;
796 color: var(--text);
797 background: var(--bg);
798 border: 1px solid var(--border-strong);
799 border-radius: 8px;
800 outline: none;
801 box-sizing: border-box;
802 transition: border-color 120ms ease, box-shadow 120ms ease;
803 font-family: inherit;
804 }
805 .wsec-input-mono { font-family: var(--font-mono); }
806 .wsec-textarea {
807 resize: vertical;
808 min-height: 110px;
809 line-height: 1.5;
810 }
811 .wsec-input:focus {
812 border-color: var(--border-focus);
813 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
814 }
815 .wsec-field-help {
816 margin: 0;
817 font-size: 11.5px;
818 color: var(--text-muted);
819 line-height: 1.45;
820 }
821 .wsec-field-help code {
822 font-family: var(--font-mono);
823 font-size: 11.5px;
824 background: var(--bg-tertiary);
825 padding: 1px 5px;
826 border-radius: 4px;
827 }
828 .wsec-form-actions { display: flex; justify-content: flex-end; }
829
830 /* ─── Buttons ─── */
831 .wsec-btn {
832 appearance: none;
833 border: 1px solid var(--border-strong);
834 background: var(--bg-secondary);
835 color: var(--text);
836 padding: 8px 14px;
837 border-radius: 8px;
838 font-family: inherit;
839 font-size: 13px;
840 font-weight: 500;
841 cursor: pointer;
842 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease, color 150ms ease;
843 text-decoration: none;
844 display: inline-flex;
845 align-items: center;
846 gap: 6px;
847 }
848 .wsec-btn:hover {
849 border-color: var(--border-focus);
850 background: rgba(255,255,255,0.04);
851 transform: translateY(-1px);
852 }
853 .wsec-btn-primary {
854 border-color: rgba(140,109,255,0.45);
855 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
856 color: var(--text-strong);
857 padding: 10px 18px;
858 font-size: 13.5px;
859 }
860 .wsec-btn-primary:hover {
861 border-color: rgba(140,109,255,0.65);
862 background: linear-gradient(135deg, rgba(140,109,255,0.28), rgba(54,197,214,0.20));
863 }
864 .wsec-btn-danger {
865 border-color: rgba(248,113,113,0.32);
866 color: #fecaca;
867 }
868 .wsec-btn-danger:hover {
869 border-color: rgba(248,113,113,0.55);
870 background: rgba(248,113,113,0.10);
871 color: #fee2e2;
872 }
873
874 @media (max-width: 640px) {
875 .wsec-row { flex-direction: column; align-items: flex-start; }
876 .wsec-row-actions { width: 100%; }
877 .wsec-row-actions form { width: 100%; }
878 .wsec-row-actions .wsec-btn { width: 100%; justify-content: center; }
879 }
880`;
881
411882export default workflowSecretsRoutes;
412883