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

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

org-secrets.tsxBlame627 lines · 1 contributor
05ab9b1Claude1/**
2 * Org-level secrets UI — Block M1.
3 *
4 * Routes:
5 * GET /orgs/:slug/settings/secrets — list secrets (admin/owner only)
6 * POST /orgs/:slug/settings/secrets — create or update a secret
7 * POST /orgs/:slug/settings/secrets/:id/delete — delete a secret
8 *
9 * Auth: requireAuth + org admin/owner check via loadOrgForUser.
10 * CSS: every selector prefixed `.osec-*` — no bleed into other surfaces.
11 */
12
13import { Hono } from "hono";
14import { Layout } from "../views/layout";
15import { softAuth, requireAuth } from "../middleware/auth";
16import type { AuthEnv } from "../middleware/auth";
17import { loadOrgForUser, orgRoleAtLeast } from "../lib/orgs";
18import {
19 listOrgSecrets,
20 upsertOrgSecret,
21 deleteOrgSecret,
22} from "../lib/org-secrets";
23import { getUnreadCount } from "../lib/unread";
24
25const orgSecretsRoutes = new Hono<AuthEnv>();
26
27// ── Auth guard ────────────────────────────────────────────────────────────────
28
29orgSecretsRoutes.use("/orgs/:slug/settings/secrets*", softAuth, requireAuth);
30
31// ── Scoped CSS (.osec-*) ──────────────────────────────────────────────────────
32
33const osecStyles = `
34 .osec-wrap {
35 max-width: 900px;
36 margin: 0 auto;
37 padding: var(--space-6) var(--space-4);
38 }
39
40 /* ─── Hero ─── */
41 .osec-hero {
42 position: relative;
43 margin-bottom: var(--space-6);
44 padding: var(--space-5) var(--space-6);
45 background: var(--bg-elevated);
46 border: 1px solid var(--border);
47 border-radius: 16px;
48 overflow: hidden;
49 }
50 .osec-hero::before {
51 content: '';
52 position: absolute;
53 top: 0; left: 0; right: 0;
54 height: 2px;
55 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
56 opacity: 0.7;
57 pointer-events: none;
58 }
59 .osec-hero-orb {
60 position: absolute;
61 inset: -20% -10% auto auto;
62 width: 360px; height: 360px;
63 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
64 filter: blur(80px);
65 opacity: 0.7;
66 pointer-events: none;
67 z-index: 0;
68 }
69 .osec-hero-inner {
70 position: relative;
71 z-index: 1;
72 max-width: 640px;
73 }
74 .osec-eyebrow {
75 font-size: 12px;
76 color: var(--text-muted);
77 margin-bottom: var(--space-2);
78 letter-spacing: 0.02em;
79 display: inline-flex;
80 align-items: center;
81 gap: 8px;
82 text-transform: uppercase;
83 font-family: var(--font-mono);
84 font-weight: 600;
85 }
86 .osec-title {
87 font-size: clamp(26px, 4vw, 38px);
88 font-family: var(--font-display);
89 font-weight: 800;
90 letter-spacing: -0.028em;
91 line-height: 1.05;
92 margin: 0 0 var(--space-2);
93 color: var(--text-strong);
94 }
95 .osec-title-grad {
96 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
97 -webkit-background-clip: text;
98 background-clip: text;
99 -webkit-text-fill-color: transparent;
100 color: transparent;
101 }
102 .osec-sub {
103 font-size: 14.5px;
104 color: var(--text-muted);
105 margin: 0;
106 line-height: 1.55;
107 }
108
109 /* ─── Info banner ─── */
110 .osec-banner {
111 display: flex;
112 gap: 12px;
113 padding: 14px 18px;
114 background: rgba(140,109,255,0.08);
115 border: 1px solid rgba(140,109,255,0.22);
116 border-radius: 12px;
117 margin-bottom: var(--space-5);
118 font-size: 13.5px;
119 color: var(--text);
120 line-height: 1.55;
121 }
122 .osec-banner-icon {
123 flex-shrink: 0;
124 width: 18px; height: 18px;
125 margin-top: 1px;
126 color: #a78bfa;
127 }
128
129 /* ─── Flash messages ─── */
130 .osec-flash {
131 display: flex;
132 align-items: flex-start;
133 gap: 10px;
134 padding: 12px 16px;
135 border-radius: 10px;
136 margin-bottom: var(--space-4);
137 font-size: 13.5px;
138 line-height: 1.45;
139 }
140 .osec-flash-ok {
141 background: rgba(34,197,94,0.10);
142 border: 1px solid rgba(34,197,94,0.26);
143 color: #86efac;
144 }
145 .osec-flash-err {
146 background: rgba(239,68,68,0.10);
147 border: 1px solid rgba(239,68,68,0.28);
148 color: #fca5a5;
149 }
150
151 /* ─── Form card ─── */
152 .osec-form-card {
153 background: var(--bg-elevated);
154 border: 1px solid var(--border);
155 border-radius: 14px;
156 padding: var(--space-5) var(--space-5);
157 margin-bottom: var(--space-6);
158 }
159 .osec-form-title {
160 font-size: 15px;
161 font-weight: 700;
162 color: var(--text-strong);
163 margin: 0 0 var(--space-4);
164 letter-spacing: -0.01em;
165 }
166 .osec-form-row {
167 display: grid;
168 grid-template-columns: 1fr 1fr auto;
169 gap: 10px;
170 align-items: end;
171 }
172 @media (max-width: 640px) {
173 .osec-form-row { grid-template-columns: 1fr; }
174 }
175 .osec-label {
176 display: block;
177 font-size: 12px;
178 font-weight: 600;
179 color: var(--text-muted);
180 text-transform: uppercase;
181 letter-spacing: 0.05em;
182 margin-bottom: 6px;
183 font-family: var(--font-mono);
184 }
185 .osec-input {
186 width: 100%;
187 padding: 9px 13px;
188 background: var(--bg-input, var(--bg));
189 border: 1px solid var(--border-strong, var(--border));
190 border-radius: 8px;
191 color: var(--text);
192 font-size: 13.5px;
193 font-family: var(--font-mono);
194 box-sizing: border-box;
195 transition: border-color 120ms ease, box-shadow 120ms ease;
196 }
197 .osec-input:focus {
198 outline: none;
199 border-color: rgba(140,109,255,0.6);
200 box-shadow: 0 0 0 3px rgba(140,109,255,0.14);
201 }
202 .osec-input.is-invalid {
203 border-color: rgba(239,68,68,0.6);
204 }
205 .osec-hint {
206 font-size: 11.5px;
207 color: var(--text-muted);
208 margin-top: 5px;
209 }
210 .osec-btn {
211 display: inline-flex;
212 align-items: center;
213 justify-content: center;
214 gap: 6px;
215 padding: 9px 18px;
216 border-radius: 9px;
217 font-size: 13.5px;
218 font-weight: 600;
219 border: 1px solid transparent;
220 cursor: pointer;
221 font-family: inherit;
222 line-height: 1;
223 transition: transform 120ms ease, box-shadow 120ms ease, background 120ms ease, border-color 120ms ease;
224 white-space: nowrap;
225 }
226 .osec-btn-primary {
227 background: linear-gradient(135deg, #8c6dff 0%, #36c5d6 100%);
228 color: #fff;
229 box-shadow: 0 5px 16px -4px rgba(140,109,255,0.45);
230 }
231 .osec-btn-primary:hover {
232 transform: translateY(-1px);
233 box-shadow: 0 9px 22px -6px rgba(140,109,255,0.55);
234 }
235 .osec-btn-danger {
236 background: transparent;
237 color: #f87171;
238 border-color: rgba(239,68,68,0.35);
239 padding: 6px 12px;
240 font-size: 12.5px;
241 }
242 .osec-btn-danger:hover {
243 background: rgba(239,68,68,0.08);
244 border-color: rgba(239,68,68,0.6);
245 }
246
247 /* ─── Secrets table ─── */
248 .osec-section-title {
249 font-size: 14px;
250 font-weight: 700;
251 color: var(--text-strong);
252 margin: 0 0 var(--space-3);
253 letter-spacing: -0.01em;
254 }
255 .osec-table-wrap {
256 border: 1px solid var(--border);
257 border-radius: 12px;
258 overflow: hidden;
259 }
260 .osec-table {
261 width: 100%;
262 border-collapse: collapse;
263 font-size: 13.5px;
264 }
265 .osec-table thead th {
266 padding: 10px 16px;
267 background: var(--bg-secondary, var(--bg-elevated));
268 border-bottom: 1px solid var(--border);
269 text-align: left;
270 font-size: 11.5px;
271 font-weight: 700;
272 color: var(--text-muted);
273 text-transform: uppercase;
274 letter-spacing: 0.05em;
275 font-family: var(--font-mono);
276 }
277 .osec-table tbody tr {
278 border-bottom: 1px solid var(--border);
279 transition: background 80ms ease;
280 }
281 .osec-table tbody tr:last-child { border-bottom: none; }
282 .osec-table tbody tr:hover { background: rgba(140,109,255,0.035); }
283 .osec-table td {
284 padding: 11px 16px;
285 color: var(--text);
286 vertical-align: middle;
287 }
288 .osec-name {
289 font-family: var(--font-mono);
290 font-weight: 600;
291 color: var(--text-strong);
292 font-size: 13px;
293 }
294 .osec-hint-val {
295 font-family: var(--font-mono);
296 color: var(--text-muted);
297 font-size: 12.5px;
298 }
299 .osec-date {
300 font-size: 12.5px;
301 color: var(--text-muted);
302 font-family: var(--font-mono);
303 }
304 .osec-actions { text-align: right; }
305
306 /* ─── Empty state ─── */
307 .osec-empty {
308 padding: 48px 24px;
309 text-align: center;
310 background: var(--bg-secondary, var(--bg-elevated));
311 }
312 .osec-empty-icon {
313 width: 40px; height: 40px;
314 margin: 0 auto 14px;
315 display: flex;
316 align-items: center;
317 justify-content: center;
318 border-radius: 12px;
319 background: rgba(140,109,255,0.12);
320 color: #a78bfa;
321 }
322 .osec-empty-title {
323 font-size: 15px;
324 font-weight: 700;
325 color: var(--text-strong);
326 margin: 0 0 6px;
327 }
328 .osec-empty-sub {
329 font-size: 13.5px;
330 color: var(--text-muted);
331 margin: 0;
332 line-height: 1.5;
333 }
334
335 /* ─── Breadcrumb ─── */
336 .osec-crumb {
337 font-size: 13px;
338 color: var(--text-muted);
339 margin-bottom: var(--space-3);
340 display: flex;
341 align-items: center;
342 gap: 6px;
343 }
344 .osec-crumb a { color: var(--text-muted); text-decoration: none; }
345 .osec-crumb a:hover { color: var(--text); }
346 .osec-crumb-sep { opacity: 0.4; }
347`;
348
349// ── Helpers ───────────────────────────────────────────────────────────────────
350
351function formatDate(d: Date): string {
352 return d.toLocaleDateString("en-US", {
353 year: "numeric",
354 month: "short",
355 day: "numeric",
356 });
357}
358
359// ── GET /orgs/:slug/settings/secrets ─────────────────────────────────────────
360
361orgSecretsRoutes.get("/orgs/:slug/settings/secrets", async (c) => {
362 const slug = c.req.param("slug");
363 const user = c.get("user")!;
364
365 const { org, role } = await loadOrgForUser(slug, user.id);
366 if (!org) return c.notFound();
367 if (!role || !orgRoleAtLeast(role, "admin")) {
368 return c.redirect(`/orgs/${slug}`);
369 }
370
371 const secrets = await listOrgSecrets(org.id);
372 const unread = await getUnreadCount(user.id);
373 const success = c.req.query("success");
374 const error = c.req.query("error");
375
376 return c.html(
377 <Layout
378 title={`Secrets — ${org.slug}`}
379 user={user}
380 notificationCount={unread}
381 >
382 <style dangerouslySetInnerHTML={{ __html: osecStyles }} />
383
384 <div class="osec-wrap">
385 {/* Breadcrumb */}
386 <nav class="osec-crumb" aria-label="breadcrumb">
387 <a href={`/orgs/${org.slug}`}>{org.slug}</a>
388 <span class="osec-crumb-sep">/</span>
389 <a href={`/orgs/${org.slug}/settings`}>settings</a>
390 <span class="osec-crumb-sep">/</span>
391 <span>secrets</span>
392 </nav>
393
394 {/* Hero */}
395 <div class="osec-hero" role="banner">
396 <div class="osec-hero-orb" aria-hidden="true" />
397 <div class="osec-hero-inner">
398 <div class="osec-eyebrow">
399 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
400 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
401 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
402 </svg>
403 Org secrets
404 </div>
405 <h1 class="osec-title">
406 <span class="osec-title-grad">Secrets.</span>
407 </h1>
408 <p class="osec-sub">
409 Encrypted values available to all workflow runs in{" "}
410 <strong>{org.slug}</strong>.
411 </p>
412 </div>
413 </div>
414
415 {/* Flash */}
416 {success && (
417 <div class="osec-flash osec-flash-ok" role="status">
418 <svg class="osec-banner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
419 <path d="M22 11.08V12a10 10 0 1 1-5.93-9.14" />
420 <polyline points="22 4 12 14.01 9 11.01" />
421 </svg>
422 {decodeURIComponent(success)}
423 </div>
424 )}
425 {error && (
426 <div class="osec-flash osec-flash-err" role="alert">
427 <svg class="osec-banner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
428 <circle cx="12" cy="12" r="10" />
429 <line x1="12" y1="8" x2="12" y2="12" />
430 <line x1="12" y1="16" x2="12.01" y2="16" />
431 </svg>
432 {decodeURIComponent(error)}
433 </div>
434 )}
435
436 {/* Info banner */}
437 <div class="osec-banner" role="note">
438 <svg class="osec-banner-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
439 <circle cx="12" cy="12" r="10" />
440 <line x1="12" y1="8" x2="12" y2="8" />
441 <line x1="12" y1="12" x2="12" y2="16" />
442 </svg>
443 These secrets are available to all workflow runs in this org. They're
444 overridden by repo-level secrets of the same name.
445 </div>
446
447 {/* Add / update form */}
448 <div class="osec-form-card">
449 <p class="osec-form-title">Add or update a secret</p>
450 <form method="post" action={`/orgs/${org.slug}/settings/secrets`}>
451 <div class="osec-form-row">
452 <div>
453 <label class="osec-label" for="osec-name">Name</label>
454 <input
455 id="osec-name"
456 class="osec-input"
457 type="text"
458 name="name"
459 required
460 placeholder="MY_SECRET_NAME"
461 pattern="[A-Z_][A-Z0-9_]*"
462 maxLength={100}
463 autocomplete="off"
464 spellcheck={false}
465 oninput="this.value=this.value.toUpperCase().replace(/[^A-Z0-9_]/g,'');this.classList.toggle('is-invalid',this.value.length>0&&!/^[A-Z_][A-Z0-9_]*$/.test(this.value))"
466 />
467 <p class="osec-hint">
468 Uppercase letters, digits, underscores — must start with a letter or
469 underscore.
470 </p>
471 </div>
472 <div>
473 <label class="osec-label" for="osec-value">Value</label>
474 <input
475 id="osec-value"
476 class="osec-input"
477 type="password"
478 name="value"
479 required
480 placeholder="••••••••••••"
481 autocomplete="new-password"
482 />
483 <p class="osec-hint">Value is encrypted at rest with AES-256-GCM.</p>
484 </div>
485 <div>
486 <button type="submit" class="osec-btn osec-btn-primary">
487 Save secret
488 </button>
489 </div>
490 </div>
491 </form>
492 </div>
493
494 {/* Secrets list */}
495 <p class="osec-section-title">
496 {secrets.length === 0
497 ? "No secrets yet"
498 : `${secrets.length} secret${secrets.length === 1 ? "" : "s"}`}
499 </p>
500
501 {secrets.length === 0 ? (
502 <div class="osec-table-wrap">
503 <div class="osec-empty">
504 <div class="osec-empty-icon" aria-hidden="true">
505 <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
506 <rect x="3" y="11" width="18" height="11" rx="2" ry="2" />
507 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
508 </svg>
509 </div>
510 <p class="osec-empty-title">No secrets configured</p>
511 <p class="osec-empty-sub">
512 Add your first secret above. It will be injected into every
513 workflow run inside <strong>{org.slug}</strong> as{" "}
514 <code>{"${{ secrets.NAME }}"}</code>.
515 </p>
516 </div>
517 </div>
518 ) : (
519 <div class="osec-table-wrap">
520 <table class="osec-table" aria-label="Org secrets">
521 <thead>
522 <tr>
523 <th scope="col">Name</th>
524 <th scope="col">Hint</th>
525 <th scope="col">Updated</th>
526 <th scope="col">
527 <span class="sr-only">Actions</span>
528 </th>
529 </tr>
530 </thead>
531 <tbody>
532 {secrets.map((s) => (
533 <tr key={s.id}>
534 <td>
535 <span class="osec-name">{s.name}</span>
536 </td>
537 <td>
538 <span class="osec-hint-val">
539 {s.keyHint ? `••••${s.keyHint}` : "—"}
540 </span>
541 </td>
542 <td>
543 <span class="osec-date">{formatDate(s.updatedAt)}</span>
544 </td>
545 <td class="osec-actions">
546 <form
547 method="post"
548 action={`/orgs/${org.slug}/settings/secrets/${s.id}/delete`}
549 onsubmit="return confirm('Delete this secret? This cannot be undone.')"
550 >
551 <button type="submit" class="osec-btn osec-btn-danger">
552 Delete
553 </button>
554 </form>
555 </td>
556 </tr>
557 ))}
558 </tbody>
559 </table>
560 </div>
561 )}
562 </div>
563 </Layout>
564 );
565});
566
567// ── POST /orgs/:slug/settings/secrets ─────────────────────────────────────────
568
569orgSecretsRoutes.post("/orgs/:slug/settings/secrets", async (c) => {
570 const slug = c.req.param("slug");
571 const user = c.get("user")!;
572
573 const { org, role } = await loadOrgForUser(slug, user.id);
574 if (!org) return c.notFound();
575 if (!role || !orgRoleAtLeast(role, "admin")) {
576 return c.redirect(`/orgs/${slug}`);
577 }
578
579 const body = await c.req.parseBody();
580 const name = String(body.name ?? "").trim();
581 const value = String(body.value ?? "");
582
583 if (!name || !value) {
584 const msg = encodeURIComponent("Name and value are required.");
585 return c.redirect(`/orgs/${slug}/settings/secrets?error=${msg}`);
586 }
587
588 try {
589 await upsertOrgSecret(org.id, name, value, user.id);
590 } catch (err) {
591 const msg = encodeURIComponent(
592 err instanceof Error ? err.message : "Failed to save secret."
593 );
594 return c.redirect(`/orgs/${slug}/settings/secrets?error=${msg}`);
595 }
596
597 const ok = encodeURIComponent(`Secret "${name}" saved.`);
598 return c.redirect(`/orgs/${slug}/settings/secrets?success=${ok}`);
599});
600
601// ── POST /orgs/:slug/settings/secrets/:id/delete ──────────────────────────────
602
603orgSecretsRoutes.post("/orgs/:slug/settings/secrets/:id/delete", async (c) => {
604 const slug = c.req.param("slug");
605 const secretId = c.req.param("id");
606 const user = c.get("user")!;
607
608 const { org, role } = await loadOrgForUser(slug, user.id);
609 if (!org) return c.notFound();
610 if (!role || !orgRoleAtLeast(role, "admin")) {
611 return c.redirect(`/orgs/${slug}`);
612 }
613
614 try {
615 await deleteOrgSecret(org.id, secretId);
616 } catch (err) {
617 const msg = encodeURIComponent(
618 err instanceof Error ? err.message : "Failed to delete secret."
619 );
620 return c.redirect(`/orgs/${slug}/settings/secrets?error=${msg}`);
621 }
622
623 const ok = encodeURIComponent("Secret deleted.");
624 return c.redirect(`/orgs/${slug}/settings/secrets?success=${ok}`);
625});
626
627export default orgSecretsRoutes;