Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

import-secrets.tsx

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

import-secrets.tsxBlame392 lines · 2 contributors
f390cfaCC LABS App1/**
2 * Block T1 — GitHub Actions secret-migration checklist UI.
3 *
4 * After a user imports a GitHub repo (see `src/routes/import.tsx`), if their
5 * GitHub repo had any Actions secrets we pre-create matching placeholder
6 * rows in `workflow_secrets` (via `src/lib/github-secrets-import.ts`) and
7 * redirect to `GET /:owner/:repo/import/secrets`. This route renders the
8 * one-shot checklist — name + status pill + a password input per row + a
9 * "Save" button per row. The Done button always works, even with empty
10 * placeholders remaining, because users can come back later via the
11 * regular `/settings/secrets` page.
12 *
13 * Why a separate route from `workflow-secrets.tsx`?
14 * - Different UX shape (vertical checklist vs add-form-on-top table)
15 * - Different copy ("we found N secrets from your GitHub repo")
16 * - Different one-shot lifecycle (cleanup empty placeholders on Done)
17 *
18 * Empty-vs-filled detection: we decrypt each row and check if plaintext
19 * is the empty string. Cheap, accurate, and avoids leaking the "is this
20 * a placeholder?" boolean as a database column (which would also force
21 * a migration for a feature we shipped behaviourally).
22 *
23 * CSRF: every POST is guarded by the global `csrfProtect` middleware
24 * (see `src/middleware/csrf.ts`); same-origin Origin/Referer check is the
25 * primary defence. Each value submission still goes through the existing
26 * `upsertRepoSecret` encryption layer — no plaintext storage.
27 */
28
29import { Hono } from "hono";
30import { and, eq } from "drizzle-orm";
31import { db } from "../db";
32import { workflowSecrets } from "../db/schema";
33import { Layout } from "../views/layout";
34import { RepoHeader, RepoNav } from "../views/components";
35import { softAuth, requireAuth } from "../middleware/auth";
36import type { AuthEnv } from "../middleware/auth";
37import { requireRepoAccess } from "../middleware/repo-access";
38import { Alert, Container, Text } from "../views/ui";
39import { upsertRepoSecret, listRepoSecrets } from "../lib/workflow-secrets";
40import { decryptSecret } from "../lib/workflow-secrets-crypto";
41import { audit } from "../lib/notify";
42
43const importSecretsRoutes = new Hono<AuthEnv>();
44
45importSecretsRoutes.use("*", softAuth);
46
47const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
48const MAX_VALUE_LEN = 32768;
49
50type ChecklistRow = {
51 id: string;
52 name: string;
53 status: "pasted" | "empty";
54};
55
56/**
57 * Load each secret row for the repo and decrypt it to determine empty
58 * (placeholder) vs filled. Returns an empty array on any DB or decrypt
59 * failure — the route handler degrades to "no rows" rather than erroring,
60 * because this UI is an optional post-import polish step.
61 */
62async function loadChecklist(repoId: string): Promise<ChecklistRow[]> {
63 let rows: { id: string; name: string; encryptedValue: string }[];
64 try {
65 rows = await db
66 .select({
67 id: workflowSecrets.id,
68 name: workflowSecrets.name,
69 encryptedValue: workflowSecrets.encryptedValue,
70 })
71 .from(workflowSecrets)
72 .where(eq(workflowSecrets.repositoryId, repoId));
73 } catch {
74 return [];
75 }
76
77 // Sort: empty placeholders first (so the user works through them), then
78 // filled rows alphabetically. Stable within each group.
79 const out: ChecklistRow[] = rows.map((r) => {
80 const dec = decryptSecret(r.encryptedValue);
81 const status: "pasted" | "empty" =
82 dec.ok && dec.plaintext === "" ? "empty" : "pasted";
83 return { id: r.id, name: r.name, status };
84 });
85 out.sort((a, b) => {
86 if (a.status !== b.status) return a.status === "empty" ? -1 : 1;
87 return a.name.localeCompare(b.name);
88 });
89 return out;
90}
91
92// ─── GET: Checklist ─────────────────────────────────────────────────────────
93
94importSecretsRoutes.get(
95 "/:owner/:repo/import/secrets",
96 requireAuth,
97 requireRepoAccess("admin"),
98 async (c) => {
99 const { owner: ownerName, repo: repoName } = c.req.param();
100 const user = c.get("user")!;
101 const repo = c.get("repository") as { id: string } | undefined;
102 if (!repo) return c.notFound();
103
104 const saved = c.req.query("saved");
105 const error = c.req.query("error");
106
107 const rows = await loadChecklist(repo.id);
108 const emptyCount = rows.filter((r) => r.status === "empty").length;
109 const pastedCount = rows.length - emptyCount;
110
111 // If the repo has zero placeholders at all (e.g. the user came back to
609a27aClaude112 // this URL after finishing earlier, or the secrets migration didn't
113 // actually find any rows — empty repo, no token, decrypt failure),
114 // redirect back to /import with a success banner explaining what
115 // happened. Previously this redirected silently to the repo home with
116 // no feedback, so users didn't know whether secrets had migrated or
117 // not.
f390cfaCC LABS App118 if (rows.length === 0) {
609a27aClaude119 return c.redirect(
120 `/import?success=${encodeURIComponent(
121 `Import of ${ownerName}/${repoName} succeeded. No secrets were migrated — either the GitHub repo has none, the token lacks the 'actions:read' scope, or you already pasted values in a previous session. Open the repo at /${ownerName}/${repoName} to start using it.`
122 )}`
123 );
f390cfaCC LABS App124 }
125
126 return c.html(
127 <Layout
128 title={`Import secrets — ${ownerName}/${repoName}`}
129 user={user}
130 >
131 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
132 <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>
158
159 {saved && (
160 <Alert variant="success">
161 Saved <code>{decodeURIComponent(saved)}</code>.
162 </Alert>
163 )}
164 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
165
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"
198 >
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 saved — paste 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>
245 </div>
246
247 <form
248 method="post"
249 action={`/${ownerName}/${repoName}/import/secrets/done`}
250 style="margin-top: 24px; display: flex; gap: 12px; align-items: center"
251 >
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>
263 </form>
264 </Container>
265 </Layout>
266 );
267 }
268);
269
270// ─── POST: Save one value ───────────────────────────────────────────────────
271
272importSecretsRoutes.post(
273 "/:owner/:repo/import/secrets/done",
274 requireAuth,
275 requireRepoAccess("admin"),
276 async (c) => {
277 const { owner: ownerName, repo: repoName } = c.req.param();
278 const user = c.get("user")!;
279 const repo = c.get("repository") as { id: string } | undefined;
280 if (!repo) return c.notFound();
281
282 const body = await c.req.parseBody();
283 const cleanup = String(body.cleanup_empty || "") === "1";
284
285 if (cleanup) {
286 try {
287 const meta = await listRepoSecrets(repo.id);
288 if (meta.ok) {
289 // Decrypt each row to find empty placeholders, then delete them
290 // by id+repo (defensive scope, same shape as deleteRepoSecret).
291 const rows = await db
292 .select({
293 id: workflowSecrets.id,
294 name: workflowSecrets.name,
295 encryptedValue: workflowSecrets.encryptedValue,
296 })
297 .from(workflowSecrets)
298 .where(eq(workflowSecrets.repositoryId, repo.id));
299 let deleted = 0;
300 for (const r of rows) {
301 const dec = decryptSecret(r.encryptedValue);
302 if (dec.ok && dec.plaintext === "") {
303 await db
304 .delete(workflowSecrets)
305 .where(
306 and(
307 eq(workflowSecrets.id, r.id),
308 eq(workflowSecrets.repositoryId, repo.id)
309 )
310 );
311 deleted++;
312 }
313 }
314 if (deleted > 0) {
315 try {
316 await audit({
317 userId: user.id,
318 repositoryId: repo.id,
319 action: "workflow.secret.import_cleanup",
320 targetType: "repository",
321 targetId: repo.id,
322 metadata: { deleted },
323 });
324 } catch {
325 // best-effort
326 }
327 }
328 }
329 } catch {
330 // Cleanup errors never block the redirect — user can re-run via
331 // the regular /settings/secrets UI.
332 }
333 }
334
335 return c.redirect(`/${ownerName}/${repoName}`);
336 }
337);
338
339importSecretsRoutes.post(
340 "/:owner/:repo/import/secrets/:name",
341 requireAuth,
342 requireRepoAccess("admin"),
343 async (c) => {
344 const { owner: ownerName, repo: repoName } = c.req.param();
345 const rawName = c.req.param("name");
346 const user = c.get("user")!;
347 const repo = c.get("repository") as { id: string } | undefined;
348 if (!repo) return c.notFound();
349
350 const base = `/${ownerName}/${repoName}/import/secrets`;
351 const fail = (msg: string) =>
352 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
353
354 const name = (rawName || "").trim();
355 if (!name || !SECRET_NAME_RE.test(name)) {
356 return fail("Invalid secret name");
357 }
358
359 const body = await c.req.parseBody();
360 const value = typeof body.value === "string" ? body.value : "";
361
362 if (!value) return fail("Value is required");
363 if (value.length > MAX_VALUE_LEN)
364 return fail(`Value must be <= ${MAX_VALUE_LEN} characters`);
365
366 const result = await upsertRepoSecret({
367 repoId: repo.id,
368 name,
369 value,
370 createdBy: user.id,
371 });
372
373 if (!result.ok) return fail(result.error);
374
375 try {
376 await audit({
377 userId: user.id,
378 repositoryId: repo.id,
379 action: "workflow.secret.import_pasted",
380 targetType: "workflow_secret",
381 targetId: result.id,
382 metadata: { name },
383 });
384 } catch {
385 // best-effort
386 }
387
388 return c.redirect(`${base}?saved=${encodeURIComponent(name)}`);
389 }
390);
391
392export default importSecretsRoutes;