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