Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsxBlame384 lines · 1 contributor
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
112 // this URL after finishing earlier), short-circuit to the regular
113 // settings page so they're not stuck on a screen showing no rows.
114 if (rows.length === 0) {
115 return c.redirect(`/${ownerName}/${repoName}`);
116 }
117
118 return c.html(
119 <Layout
120 title={`Import secrets — ${ownerName}/${repoName}`}
121 user={user}
122 >
123 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
124 <RepoNav owner={ownerName} repo={repoName} active="settings" />
125 <Container maxWidth={780}>
126 <div
127 style="position: sticky; top: 0; background: var(--bg); padding: 20px 0 16px; border-bottom: 1px solid var(--border); margin-bottom: 20px; z-index: 5"
128 >
129 <h2 style="margin-bottom: 6px">
130 Migrate {rows.length} secret{rows.length === 1 ? "" : "s"} from GitHub
131 </h2>
132 <Text size={14} muted style="display:block;margin-bottom:8px">
133 We found {rows.length} Actions secret{rows.length === 1 ? "" : "s"} on
134 your GitHub repo. Paste each value below to migrate them.{" "}
135 <strong>
136 {pastedCount} pasted · {emptyCount} still empty
137 </strong>
138 .
139 </Text>
140 <Text size={13} muted style="display:block">
141 Need the value? Find it in{" "}
142 <code style="font-family: var(--font-mono); background: var(--bg-secondary); padding: 1px 6px; border-radius: 4px">
143 github.com/{ownerName}/{repoName}/settings/secrets/actions
144 </code>
145 {" "}— each value is opaque so you can't read it from GitHub, you'll
146 need to copy from wherever you originally created it
147 (1Password, .env file, etc.).
148 </Text>
149 </div>
150
151 {saved && (
152 <Alert variant="success">
153 Saved <code>{decodeURIComponent(saved)}</code>.
154 </Alert>
155 )}
156 {error && <Alert variant="error">{decodeURIComponent(error)}</Alert>}
157
158 <div
159 class="panel"
160 style="padding: 0; overflow: hidden; margin-top: 8px"
161 >
162 <table
163 class="file-table"
164 style="width: 100%; border-collapse: collapse"
165 >
166 <thead>
167 <tr style="background: var(--bg-secondary); text-align: left">
168 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); width: 40%">
169 Secret name
170 </th>
171 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted); width: 15%">
172 Status
173 </th>
174 <th style="padding: 10px 14px; font-size: 12px; text-transform: uppercase; letter-spacing: 0.5px; color: var(--text-muted)">
175 Value
176 </th>
177 </tr>
178 </thead>
179 <tbody>
180 {rows.map((row) => {
181 const action = `/${ownerName}/${repoName}/import/secrets/${encodeURIComponent(row.name)}`;
182 const pillStyle =
183 row.status === "pasted"
184 ? "background: var(--green-dim, #1f3d2a); color: var(--green, #3fb950); border: 1px solid var(--green, #3fb950)"
185 : "background: var(--yellow-dim, #3d3520); color: var(--yellow, #d29922); border: 1px solid var(--yellow, #d29922)";
186 return (
187 <tr
188 data-secret-row={row.name}
189 style="border-top: 1px solid var(--border); vertical-align: middle"
190 >
191 <td style="padding: 10px 14px">
192 <code style="font-family: var(--font-mono); font-size: 13px">
193 {row.name}
194 </code>
195 </td>
196 <td style="padding: 10px 14px">
197 <span
198 data-status-pill
199 style={
200 "display: inline-block; padding: 2px 10px; border-radius: 12px; font-size: 12px; font-weight: 500; " +
201 pillStyle
202 }
203 >
204 {row.status === "pasted" ? "Pasted" : "Empty"}
205 </span>
206 </td>
207 <td style="padding: 10px 14px">
208 <form method="post" action={action} style="display: flex; gap: 8px">
209 <input
210 type="password"
211 name="value"
212 required
213 maxlength={MAX_VALUE_LEN}
214 placeholder={
215 row.status === "pasted"
216 ? "Already saved — paste new value to overwrite"
217 : "Paste value"
218 }
219 autocomplete="off"
220 spellcheck={false}
221 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)"
222 />
223 <button
224 type="submit"
225 class="btn btn-primary"
226 style="padding: 6px 14px"
227 >
228 Save
229 </button>
230 </form>
231 </td>
232 </tr>
233 );
234 })}
235 </tbody>
236 </table>
237 </div>
238
239 <form
240 method="post"
241 action={`/${ownerName}/${repoName}/import/secrets/done`}
242 style="margin-top: 24px; display: flex; gap: 12px; align-items: center"
243 >
244 <button type="submit" class="btn btn-primary">
245 Done — take me to my repo
246 </button>
247 <label style="font-size: 13px; color: var(--text-muted); display: flex; align-items: center; gap: 6px">
248 <input
249 type="checkbox"
250 name="cleanup_empty"
251 value="1"
252 />
253 Also delete the {emptyCount} empty placeholder{emptyCount === 1 ? "" : "s"} on my way out
254 </label>
255 </form>
256 </Container>
257 </Layout>
258 );
259 }
260);
261
262// ─── POST: Save one value ───────────────────────────────────────────────────
263
264importSecretsRoutes.post(
265 "/:owner/:repo/import/secrets/done",
266 requireAuth,
267 requireRepoAccess("admin"),
268 async (c) => {
269 const { owner: ownerName, repo: repoName } = c.req.param();
270 const user = c.get("user")!;
271 const repo = c.get("repository") as { id: string } | undefined;
272 if (!repo) return c.notFound();
273
274 const body = await c.req.parseBody();
275 const cleanup = String(body.cleanup_empty || "") === "1";
276
277 if (cleanup) {
278 try {
279 const meta = await listRepoSecrets(repo.id);
280 if (meta.ok) {
281 // Decrypt each row to find empty placeholders, then delete them
282 // by id+repo (defensive scope, same shape as deleteRepoSecret).
283 const rows = await db
284 .select({
285 id: workflowSecrets.id,
286 name: workflowSecrets.name,
287 encryptedValue: workflowSecrets.encryptedValue,
288 })
289 .from(workflowSecrets)
290 .where(eq(workflowSecrets.repositoryId, repo.id));
291 let deleted = 0;
292 for (const r of rows) {
293 const dec = decryptSecret(r.encryptedValue);
294 if (dec.ok && dec.plaintext === "") {
295 await db
296 .delete(workflowSecrets)
297 .where(
298 and(
299 eq(workflowSecrets.id, r.id),
300 eq(workflowSecrets.repositoryId, repo.id)
301 )
302 );
303 deleted++;
304 }
305 }
306 if (deleted > 0) {
307 try {
308 await audit({
309 userId: user.id,
310 repositoryId: repo.id,
311 action: "workflow.secret.import_cleanup",
312 targetType: "repository",
313 targetId: repo.id,
314 metadata: { deleted },
315 });
316 } catch {
317 // best-effort
318 }
319 }
320 }
321 } catch {
322 // Cleanup errors never block the redirect — user can re-run via
323 // the regular /settings/secrets UI.
324 }
325 }
326
327 return c.redirect(`/${ownerName}/${repoName}`);
328 }
329);
330
331importSecretsRoutes.post(
332 "/:owner/:repo/import/secrets/:name",
333 requireAuth,
334 requireRepoAccess("admin"),
335 async (c) => {
336 const { owner: ownerName, repo: repoName } = c.req.param();
337 const rawName = c.req.param("name");
338 const user = c.get("user")!;
339 const repo = c.get("repository") as { id: string } | undefined;
340 if (!repo) return c.notFound();
341
342 const base = `/${ownerName}/${repoName}/import/secrets`;
343 const fail = (msg: string) =>
344 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
345
346 const name = (rawName || "").trim();
347 if (!name || !SECRET_NAME_RE.test(name)) {
348 return fail("Invalid secret name");
349 }
350
351 const body = await c.req.parseBody();
352 const value = typeof body.value === "string" ? body.value : "";
353
354 if (!value) return fail("Value is required");
355 if (value.length > MAX_VALUE_LEN)
356 return fail(`Value must be <= ${MAX_VALUE_LEN} characters`);
357
358 const result = await upsertRepoSecret({
359 repoId: repo.id,
360 name,
361 value,
362 createdBy: user.id,
363 });
364
365 if (!result.ok) return fail(result.error);
366
367 try {
368 await audit({
369 userId: user.id,
370 repositoryId: repo.id,
371 action: "workflow.secret.import_pasted",
372 targetType: "workflow_secret",
373 targetId: result.id,
374 metadata: { name },
375 });
376 } catch {
377 // best-effort
378 }
379
380 return c.redirect(`${base}?saved=${encodeURIComponent(name)}`);
381 }
382);
383
384export default importSecretsRoutes;