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 | ||
| 29 | orgSecretsRoutes.use("/orgs/:slug/settings/secrets*", softAuth, requireAuth); | |
| 30 | ||
| 31 | // ── Scoped CSS (.osec-*) ────────────────────────────────────────────────────── | |
| 32 | ||
| 33 | const 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 | ||
| 351 | function 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 | ||
| 361 | orgSecretsRoutes.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 | ||
| 569 | orgSecretsRoutes.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 | ||
| 603 | orgSecretsRoutes.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 | ||
| 627 | export default orgSecretsRoutes; |