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

workflow-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.

workflow-secrets.tsxBlame411 lines · 1 contributor
abfa9adClaude1/**
2 * Per-repo workflow secrets — settings UI for listing, creating, and deleting
3 * encrypted secrets that are substituted into workflow steps at runtime.
4 *
5 * Shape mirrors `src/routes/tokens.tsx` + `src/routes/webhooks.tsx`:
6 * - JSX page rendered through Layout + RepoHeader + RepoNav (active=settings).
7 * - Flash state via `?added=NAME | ?deleted=1 | ?error=...` query params.
8 * - Every mutating route is gated on `requireAuth` + `requireRepoAccess("admin")`.
9 *
10 * The UI never sees plaintext values after upsert — the `listRepoSecrets`
11 * helper in `../lib/workflow-secrets` returns metadata only (id, name,
12 * createdAt, createdBy). This file's sole job is to render and mutate.
13 */
14
15import { Hono } from "hono";
16import { eq, inArray } from "drizzle-orm";
17import { db } from "../db";
18import { users } from "../db/schema";
19import { Layout } from "../views/layout";
20import { RepoHeader, RepoNav } from "../views/components";
21import { softAuth, requireAuth } from "../middleware/auth";
22import type { AuthEnv } from "../middleware/auth";
23import { requireRepoAccess } from "../middleware/repo-access";
24import {
25 Container,
26 Alert,
27 EmptyState,
28 Button,
29 Text,
30 formatRelative,
31} from "../views/ui";
32import {
33 listRepoSecrets,
34 upsertRepoSecret,
35 deleteRepoSecret,
36} from "../lib/workflow-secrets";
37import { audit } from "../lib/notify";
38
39const workflowSecretsRoutes = new Hono<AuthEnv>();
40
41workflowSecretsRoutes.use("*", softAuth);
42
43const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
44const MAX_NAME_LEN = 100;
45const MAX_VALUE_LEN = 32768;
46
47/** Sub-nav shown under the main RepoNav for repo settings pages. */
48function SettingsSubNav({
49 owner,
50 repo,
51 active,
52}: {
53 owner: string;
54 repo: string;
55 active: "general" | "collaborators" | "webhooks" | "secrets";
56}) {
57 const link = (
58 href: string,
59 label: string,
60 key: "general" | "collaborators" | "webhooks" | "secrets"
61 ) => (
62 <a
63 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)")
69 }
70 >
71 {label}
72 </a>
73 );
74 return (
75 <div
76 style="display: flex; gap: 4px; margin: 12px 0 20px; padding-bottom: 12px; border-bottom: 1px solid var(--border)"
77 >
78 {link(`/${owner}/${repo}/settings`, "General", "general")}
79 {link(
80 `/${owner}/${repo}/settings/collaborators`,
81 "Collaborators",
82 "collaborators"
83 )}
84 {link(
85 `/${owner}/${repo}/settings/webhooks`,
86 "Webhooks",
87 "webhooks"
88 )}
89 {link(
90 `/${owner}/${repo}/settings/secrets`,
91 "Secrets",
92 "secrets"
93 )}
94 </div>
95 );
96}
97
98// ─── GET: List + add form ───────────────────────────────────────────────────
99
100workflowSecretsRoutes.get(
101 "/:owner/:repo/settings/secrets",
102 requireAuth,
103 requireRepoAccess("admin"),
104 async (c) => {
105 const { owner: ownerName, repo: repoName } = c.req.param();
106 const user = c.get("user")!;
107 const repo = c.get("repository") as { id: string } | undefined;
108
109 const added = c.req.query("added");
110 const deleted = c.req.query("deleted");
111 const error = c.req.query("error");
112
113 if (!repo) {
114 return c.notFound();
115 }
116
117 const result = await listRepoSecrets(repo.id);
118 const secrets = result.ok ? result.secrets : [];
119 const loadError = result.ok ? null : result.error;
120
121 // Resolve createdBy user ids -> usernames for display/linking.
122 const creatorIds = Array.from(
123 new Set(secrets.map((s) => s.createdBy).filter(Boolean) as string[])
124 );
125 const creatorRows = creatorIds.length
126 ? await db
127 .select({ id: users.id, username: users.username })
128 .from(users)
129 .where(inArray(users.id, creatorIds))
130 : [];
131 const creatorMap = new Map(creatorRows.map((r) => [r.id, r.username]));
132
133 return c.html(
134 <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}>
135 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
136 <RepoNav owner={ownerName} repo={repoName} active="settings" />
137 <Container maxWidth={760}>
138 <SettingsSubNav
139 owner={ownerName}
140 repo={repoName}
141 active="secrets"
142 />
143
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>
155
156 {added && (
157 <Alert variant="success">
158 Secret <code>{decodeURIComponent(added)}</code> saved.
159 </Alert>
160 )}
161 {deleted && <Alert variant="success">Secret deleted.</Alert>}
162 {error && (
163 <Alert variant="error">{decodeURIComponent(error)}</Alert>
164 )}
165 {loadError && (
166 <Alert variant="error">
167 Could not load secrets: {loadError}
168 </Alert>
169 )}
170
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>
177 </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>
198 {secrets.map((s) => {
199 const creator = s.createdBy
200 ? creatorMap.get(s.createdBy)
201 : null;
202 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
217 </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">
233 <form
234 method="post"
235 action={`/${ownerName}/${repoName}/settings/secrets/${s.id}/delete`}
236 style="display: inline"
237 onsubmit={`return confirm('Delete secret ${s.name}?')`}
238 >
239 <Button
240 type="submit"
241 variant="danger"
242 size="sm"
243 >
244 Delete
245 </Button>
246 </form>
247 </td>
248 </tr>
249 );
250 })}
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 />
282 </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 />
296 </div>
297 <button type="submit" class="btn btn-primary">
298 Add secret
299 </button>
300 </form>
301 </Container>
302 </Layout>
303 );
304 }
305);
306
307// ─── POST: Create / update ──────────────────────────────────────────────────
308
309workflowSecretsRoutes.post(
310 "/:owner/:repo/settings/secrets",
311 requireAuth,
312 requireRepoAccess("admin"),
313 async (c) => {
314 const { owner: ownerName, repo: repoName } = c.req.param();
315 const user = c.get("user")!;
316 const repo = c.get("repository") as { id: string } | undefined;
317 if (!repo) return c.notFound();
318
319 const body = await c.req.parseBody();
320 const name = String(body.name || "").trim();
321 const value = typeof body.value === "string" ? body.value : "";
322
323 const base = `/${ownerName}/${repoName}/settings/secrets`;
324 const fail = (msg: string) =>
325 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
326
327 if (!name) return fail("Name is required");
328 if (name.length > MAX_NAME_LEN)
329 return fail(`Name must be ≤ ${MAX_NAME_LEN} characters`);
330 if (!SECRET_NAME_RE.test(name))
331 return fail(
332 "Name must be uppercase letters, digits, and underscores, and cannot start with a digit"
333 );
334 if (!value) return fail("Value is required");
335 if (value.length > MAX_VALUE_LEN)
336 return fail(`Value must be ≤ ${MAX_VALUE_LEN} characters`);
337
338 const result = await upsertRepoSecret({
339 repoId: repo.id,
340 name,
341 value,
342 createdBy: user.id,
343 });
344
345 if (!result.ok) return fail(result.error);
346
347 // Best-effort audit — swallow any error so it never breaks the redirect.
348 try {
349 await audit({
350 userId: user.id,
351 repositoryId: repo.id,
352 action: "workflow.secret.create",
353 targetType: "workflow_secret",
354 targetId: result.id,
355 metadata: { name },
356 });
357 } catch {
358 // audit() already swallows errors, but guard anyway.
359 }
360
361 return c.redirect(`${base}?added=${encodeURIComponent(name)}`);
362 }
363);
364
365// ─── POST: Delete ───────────────────────────────────────────────────────────
366
367workflowSecretsRoutes.post(
368 "/:owner/:repo/settings/secrets/:secretId/delete",
369 requireAuth,
370 requireRepoAccess("admin"),
371 async (c) => {
372 const { owner: ownerName, repo: repoName } = c.req.param();
373 const user = c.get("user")!;
374 const repo = c.get("repository") as { id: string } | undefined;
375 if (!repo) return c.notFound();
376
377 const secretId = c.req.param("secretId");
378 const base = `/${ownerName}/${repoName}/settings/secrets`;
379
380 if (!secretId) {
381 return c.redirect(`${base}?error=${encodeURIComponent("Missing secret id")}`);
382 }
383
384 const result = await deleteRepoSecret({
385 repoId: repo.id,
386 secretId,
387 });
388
389 if (!result.ok) {
390 return c.redirect(
391 `${base}?error=${encodeURIComponent(result.error)}`
392 );
393 }
394
395 try {
396 await audit({
397 userId: user.id,
398 repositoryId: repo.id,
399 action: "workflow.secret.delete",
400 targetType: "workflow_secret",
401 targetId: secretId,
402 });
403 } catch {
404 // best-effort
405 }
406
407 return c.redirect(`${base}?deleted=1`);
408 }
409);
410
411export default workflowSecretsRoutes;