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.
| 05ab9b1 | 1 | /** |
| 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 | ||
| 13 | import { Hono } from "hono"; | |
| 14 | import { Layout } from "../views/layout"; | |
| 15 | import { softAuth, requireAuth } from "../middleware/auth"; | |
| 16 | import type { AuthEnv } from "../middleware/auth"; | |
| 17 | import { loadOrgForUser, orgRoleAtLeast } from "../lib/orgs"; | |
| 18 | import { | |
| 19 | listOrgSecrets, | |
| 20 | upsertOrgSecret, | |
| 21 | deleteOrgSecret, | |
| 22 | } from "../lib/org-secrets"; | |
| 23 | import { getUnreadCount } from "../lib/unread"; | |
| 24 | ||
| 25 | const orgSecretsRoutes = new Hono<AuthEnv>(); | |
| 26 | ||
| 27 | // ── Auth guard ──────────────────────────────────────────────────────────────── | |
| 28 | ||
| 03e6f9b | 29 | orgSecretsRoutes.use("/orgs/:slug/settings/secrets", softAuth, requireAuth); |
| 30 | orgSecretsRoutes.use("/orgs/:slug/settings/secrets/*", softAuth, requireAuth); | |
| 05ab9b1 | 31 | |
| 32 | // ── Scoped CSS (.osec-*) ────────────────────────────────────────────────────── | |
| 33 | ||
| 34 | const 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; | |
| 6fd5915 | 56 | background: linear-gradient(90deg, transparent 0%, #5b6ee8 30%, #5f8fa0 70%, transparent 100%); |
| 05ab9b1 | 57 | 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; | |
| 6fd5915 | 64 | background: radial-gradient(circle, rgba(91,110,232,0.20), rgba(95,143,160,0.10) 45%, transparent 70%); |
| 05ab9b1 | 65 | 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 { | |
| 6fd5915 | 97 | background-image: linear-gradient(135deg, #5b6ee8 0%, #5b6ee8 50%, #5f8fa0 100%); |
| 05ab9b1 | 98 | -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; | |
| 6fd5915 | 115 | background: rgba(91,110,232,0.08); |
| 116 | border: 1px solid rgba(91,110,232,0.22); | |
| 05ab9b1 | 117 | 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; | |
| 6fd5915 | 200 | border-color: rgba(91,110,232,0.6); |
| 201 | box-shadow: 0 0 0 3px rgba(91,110,232,0.14); | |
| 05ab9b1 | 202 | } |
| 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 { | |
| 6fd5915 | 228 | background: linear-gradient(135deg, #5b6ee8 0%, #5f8fa0 100%); |
| 05ab9b1 | 229 | color: #fff; |
| 6fd5915 | 230 | box-shadow: 0 5px 16px -4px rgba(91,110,232,0.45); |
| 05ab9b1 | 231 | } |
| 232 | .osec-btn-primary:hover { | |
| 233 | transform: translateY(-1px); | |
| 6fd5915 | 234 | box-shadow: 0 9px 22px -6px rgba(91,110,232,0.55); |
| 05ab9b1 | 235 | } |
| 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; } | |
| 6fd5915 | 283 | .osec-table tbody tr:hover { background: rgba(91,110,232,0.035); } |
| 05ab9b1 | 284 | .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; | |
| 6fd5915 | 320 | background: rgba(91,110,232,0.12); |
| 05ab9b1 | 321 | 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 | ||
| 352 | function 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 | ||
| 362 | orgSecretsRoutes.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 | ||
| 570 | orgSecretsRoutes.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 | ||
| 604 | orgSecretsRoutes.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 | ||
| 628 | export default orgSecretsRoutes; |