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.tsxBlame882 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.
54eab24Claude13 *
14 * 2026 polish: scoped `.wsec-` CSS, hero with eyebrow + gradient title,
15 * masked-value list with mono names + last-used timestamps + revoke
16 * actions, and a separate add-new card. No shared file touches.
abfa9adClaude17 */
18
19import { Hono } from "hono";
54eab24Claude20import { inArray } from "drizzle-orm";
abfa9adClaude21import { db } from "../db";
22import { users } from "../db/schema";
23import { Layout } from "../views/layout";
24import { RepoHeader, RepoNav } from "../views/components";
25import { softAuth, requireAuth } from "../middleware/auth";
26import type { AuthEnv } from "../middleware/auth";
27import { requireRepoAccess } from "../middleware/repo-access";
54eab24Claude28import { formatRelative } from "../views/ui";
abfa9adClaude29import {
30 listRepoSecrets,
31 upsertRepoSecret,
32 deleteRepoSecret,
33} from "../lib/workflow-secrets";
34import { audit } from "../lib/notify";
35
36const workflowSecretsRoutes = new Hono<AuthEnv>();
37
38workflowSecretsRoutes.use("*", softAuth);
39
40const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
41const MAX_NAME_LEN = 100;
42const MAX_VALUE_LEN = 32768;
43
44/** Sub-nav shown under the main RepoNav for repo settings pages. */
45function SettingsSubNav({
46 owner,
47 repo,
48 active,
49}: {
50 owner: string;
51 repo: string;
52 active: "general" | "collaborators" | "webhooks" | "secrets";
53}) {
54 const link = (
55 href: string,
56 label: string,
57 key: "general" | "collaborators" | "webhooks" | "secrets"
58 ) => (
59 <a
60 href={href}
54eab24Claude61 class={
62 "wsec-subnav-link" + (active === key ? " is-active" : "")
abfa9adClaude63 }
64 >
65 {label}
66 </a>
67 );
68 return (
54eab24Claude69 <nav class="wsec-subnav" aria-label="Repository settings">
abfa9adClaude70 {link(`/${owner}/${repo}/settings`, "General", "general")}
71 {link(
72 `/${owner}/${repo}/settings/collaborators`,
73 "Collaborators",
74 "collaborators"
75 )}
76 {link(
77 `/${owner}/${repo}/settings/webhooks`,
78 "Webhooks",
79 "webhooks"
80 )}
81 {link(
82 `/${owner}/${repo}/settings/secrets`,
83 "Secrets",
84 "secrets"
85 )}
54eab24Claude86 </nav>
abfa9adClaude87 );
88}
89
54eab24Claude90/**
91 * Best-effort masked preview of a secret. We never have the plaintext on
92 * disk, so this is a stable visual placeholder (always 12 bullets). The
93 * dot count is deliberately constant so the list doesn't leak length.
94 */
95const MASKED_PLACEHOLDER = "••••••••••••";
96
abfa9adClaude97// ─── GET: List + add form ───────────────────────────────────────────────────
98
99workflowSecretsRoutes.get(
100 "/:owner/:repo/settings/secrets",
101 requireAuth,
102 requireRepoAccess("admin"),
103 async (c) => {
104 const { owner: ownerName, repo: repoName } = c.req.param();
105 const user = c.get("user")!;
106 const repo = c.get("repository") as { id: string } | undefined;
107
108 const added = c.req.query("added");
109 const deleted = c.req.query("deleted");
110 const error = c.req.query("error");
111
112 if (!repo) {
113 return c.notFound();
114 }
115
116 const result = await listRepoSecrets(repo.id);
117 const secrets = result.ok ? result.secrets : [];
118 const loadError = result.ok ? null : result.error;
119
120 // Resolve createdBy user ids -> usernames for display/linking.
121 const creatorIds = Array.from(
122 new Set(secrets.map((s) => s.createdBy).filter(Boolean) as string[])
123 );
124 const creatorRows = creatorIds.length
125 ? await db
126 .select({ id: users.id, username: users.username })
127 .from(users)
128 .where(inArray(users.id, creatorIds))
129 : [];
130 const creatorMap = new Map(creatorRows.map((r) => [r.id, r.username]));
131
132 return c.html(
133 <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}>
134 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
135 <RepoNav owner={ownerName} repo={repoName} active="settings" />
54eab24Claude136 <style dangerouslySetInnerHTML={{ __html: wsecStyles }} />
137 <div class="wsec-wrap">
abfa9adClaude138 <SettingsSubNav
139 owner={ownerName}
140 repo={repoName}
141 active="secrets"
142 />
143
54eab24Claude144 {/* ─── Hero ─── */}
145 <section class="wsec-hero">
146 <div class="wsec-hero-orb" aria-hidden="true" />
147 <div class="wsec-hero-inner">
148 <div class="wsec-eyebrow">
149 <span class="wsec-eyebrow-pill" aria-hidden="true">
150 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
151 <rect x="3" y="11" width="18" height="11" rx="2" />
152 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
153 </svg>
154 </span>
155 Workflow secrets · <strong>{ownerName}/{repoName}</strong>
156 </div>
157 <h1 class="wsec-title">
158 Secrets, <span class="wsec-title-grad">encrypted at rest.</span>
159 </h1>
160 <p class="wsec-sub">
161 Reference them in YAML as{" "}
162 <code>{"${{ secrets.NAME }}"}</code>. Values are write-only —
163 after saving, only the name is visible. They're never printed
164 to workflow logs.
165 </p>
166 </div>
167 </section>
abfa9adClaude168
54eab24Claude169 {/* ─── Flash banners ─── */}
abfa9adClaude170 {added && (
54eab24Claude171 <div class="wsec-banner is-ok">
172 <span class="wsec-banner-dot" aria-hidden="true" />
abfa9adClaude173 Secret <code>{decodeURIComponent(added)}</code> saved.
54eab24Claude174 </div>
175 )}
176 {deleted && (
177 <div class="wsec-banner is-ok">
178 <span class="wsec-banner-dot" aria-hidden="true" />
179 Secret deleted.
180 </div>
abfa9adClaude181 )}
182 {error && (
54eab24Claude183 <div class="wsec-banner is-error">
184 <span class="wsec-banner-dot" aria-hidden="true" />
185 {decodeURIComponent(error)}
186 </div>
abfa9adClaude187 )}
188 {loadError && (
54eab24Claude189 <div class="wsec-banner is-error">
190 <span class="wsec-banner-dot" aria-hidden="true" />
abfa9adClaude191 Could not load secrets: {loadError}
54eab24Claude192 </div>
abfa9adClaude193 )}
194
54eab24Claude195 {/* ─── Secrets list ─── */}
196 <section class="wsec-section" aria-labelledby="wsec-list-h">
197 <header class="wsec-section-head">
198 <div>
199 <h2 class="wsec-section-title" id="wsec-list-h">
200 Stored secrets
201 </h2>
202 <p class="wsec-section-sub">
203 {secrets.length === 0
204 ? "Add your first secret below."
205 : `${secrets.length} secret${secrets.length === 1 ? "" : "s"} available to workflows in this repo.`}
206 </p>
abfa9adClaude207 </div>
54eab24Claude208 <span class="wsec-count-pill">
209 {secrets.length} / unlimited
210 </span>
211 </header>
212 <div class="wsec-section-body">
213 {secrets.length === 0 ? (
214 <div class="wsec-empty">
215 <div class="wsec-empty-orb" aria-hidden="true" />
216 <div class="wsec-empty-inner">
217 <p class="wsec-empty-title">No secrets yet.</p>
218 <p class="wsec-empty-sub">
219 Add an encrypted secret below — names like{" "}
220 <code>DEPLOY_TOKEN</code> or <code>STRIPE_KEY</code>{" "}
221 become available to every workflow step in this repo.
222 </p>
223 </div>
224 </div>
225 ) : (
226 <ul class="wsec-list">
abfa9adClaude227 {secrets.map((s) => {
228 const creator = s.createdBy
229 ? creatorMap.get(s.createdBy)
230 : null;
54eab24Claude231 const created = s.createdAt
232 ? formatRelative(s.createdAt)
233 : "—";
abfa9adClaude234 return (
54eab24Claude235 <li class="wsec-row">
236 <div class="wsec-row-main">
237 <div class="wsec-row-name-row">
238 <code class="wsec-row-name">{s.name}</code>
239 <span class="wsec-row-masked" aria-label="value hidden">
240 {MASKED_PLACEHOLDER}
abfa9adClaude241 </span>
54eab24Claude242 </div>
243 <div class="wsec-row-meta">
244 <span class="wsec-meta-item">
245 Added {created}
246 </span>
247 <span class="wsec-meta-sep" aria-hidden="true">·</span>
248 <span class="wsec-meta-item">
249 by{" "}
250 {creator ? (
251 <a
252 href={`/${creator}`}
253 class="wsec-meta-link"
254 >
255 {creator}
256 </a>
257 ) : (
258 <span class="wsec-meta-faint">unknown</span>
259 )}
260 </span>
261 </div>
262 </div>
263 <div class="wsec-row-actions">
abfa9adClaude264 <form
265 method="post"
266 action={`/${ownerName}/${repoName}/settings/secrets/${s.id}/delete`}
54eab24Claude267 onsubmit={`return confirm('Revoke secret ${s.name}? Workflow steps that reference it will fail until you add it again.')`}
abfa9adClaude268 >
54eab24Claude269 <button
abfa9adClaude270 type="submit"
54eab24Claude271 class="wsec-btn wsec-btn-danger"
abfa9adClaude272 >
54eab24Claude273 Revoke
274 </button>
abfa9adClaude275 </form>
54eab24Claude276 </div>
277 </li>
abfa9adClaude278 );
279 })}
54eab24Claude280 </ul>
281 )}
abfa9adClaude282 </div>
54eab24Claude283 </section>
284
285 {/* ─── Add new ─── */}
286 <section class="wsec-section" aria-labelledby="wsec-add-h">
287 <header class="wsec-section-head">
288 <div>
289 <h2 class="wsec-section-title" id="wsec-add-h">
290 Add a new secret
291 </h2>
292 <p class="wsec-section-sub">
293 Names must be uppercase letters, digits, and underscores,
294 and cannot start with a digit. Adding a secret with an
295 existing name replaces the stored value.
296 </p>
297 </div>
298 </header>
299 <div class="wsec-section-body">
300 <form
301 method="post"
302 action={`/${ownerName}/${repoName}/settings/secrets`}
303 class="wsec-form"
304 >
305 <div class="wsec-field">
306 <label class="wsec-field-label" for="secret-name">
307 Name
308 </label>
309 <input
310 type="text"
311 id="secret-name"
312 name="name"
313 required
314 pattern="[A-Z_][A-Z0-9_]*"
315 maxlength={MAX_NAME_LEN}
316 placeholder="DEPLOY_TOKEN"
317 autocomplete="off"
318 class="wsec-input wsec-input-mono"
319 title="Uppercase letters, digits, and underscores; cannot start with a digit"
320 />
321 <p class="wsec-field-help">
322 Referenced in YAML as{" "}
323 <code>{"${{ secrets.YOUR_NAME }}"}</code>.
324 </p>
325 </div>
326 <div class="wsec-field">
327 <label class="wsec-field-label" for="secret-value">
328 Value
329 </label>
330 <textarea
331 id="secret-value"
332 name="value"
333 required
334 rows={5}
335 maxlength={MAX_VALUE_LEN}
336 placeholder="Paste secret value"
337 autocomplete="off"
338 spellcheck={false}
339 class="wsec-input wsec-input-mono wsec-textarea"
340 />
341 <p class="wsec-field-help">
342 Encrypted with AES-256-GCM before the row hits the
343 database. We never write it to logs.
344 </p>
345 </div>
346 <div class="wsec-form-actions">
347 <button
348 type="submit"
349 class="wsec-btn wsec-btn-primary"
350 >
351 Add secret
352 </button>
353 </div>
354 </form>
abfa9adClaude355 </div>
54eab24Claude356 </section>
357 </div>
abfa9adClaude358 </Layout>
359 );
360 }
361);
362
363// ─── POST: Create / update ──────────────────────────────────────────────────
364
365workflowSecretsRoutes.post(
366 "/:owner/:repo/settings/secrets",
367 requireAuth,
368 requireRepoAccess("admin"),
369 async (c) => {
370 const { owner: ownerName, repo: repoName } = c.req.param();
371 const user = c.get("user")!;
372 const repo = c.get("repository") as { id: string } | undefined;
373 if (!repo) return c.notFound();
374
375 const body = await c.req.parseBody();
376 const name = String(body.name || "").trim();
377 const value = typeof body.value === "string" ? body.value : "";
378
379 const base = `/${ownerName}/${repoName}/settings/secrets`;
380 const fail = (msg: string) =>
381 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
382
383 if (!name) return fail("Name is required");
384 if (name.length > MAX_NAME_LEN)
385 return fail(`Name must be ≤ ${MAX_NAME_LEN} characters`);
386 if (!SECRET_NAME_RE.test(name))
387 return fail(
388 "Name must be uppercase letters, digits, and underscores, and cannot start with a digit"
389 );
390 if (!value) return fail("Value is required");
391 if (value.length > MAX_VALUE_LEN)
392 return fail(`Value must be ≤ ${MAX_VALUE_LEN} characters`);
393
394 const result = await upsertRepoSecret({
395 repoId: repo.id,
396 name,
397 value,
398 createdBy: user.id,
399 });
400
401 if (!result.ok) return fail(result.error);
402
403 // Best-effort audit — swallow any error so it never breaks the redirect.
404 try {
405 await audit({
406 userId: user.id,
407 repositoryId: repo.id,
408 action: "workflow.secret.create",
409 targetType: "workflow_secret",
410 targetId: result.id,
411 metadata: { name },
412 });
413 } catch {
414 // audit() already swallows errors, but guard anyway.
415 }
416
417 return c.redirect(`${base}?added=${encodeURIComponent(name)}`);
418 }
419);
420
421// ─── POST: Delete ───────────────────────────────────────────────────────────
422
423workflowSecretsRoutes.post(
424 "/:owner/:repo/settings/secrets/:secretId/delete",
425 requireAuth,
426 requireRepoAccess("admin"),
427 async (c) => {
428 const { owner: ownerName, repo: repoName } = c.req.param();
429 const user = c.get("user")!;
430 const repo = c.get("repository") as { id: string } | undefined;
431 if (!repo) return c.notFound();
432
433 const secretId = c.req.param("secretId");
434 const base = `/${ownerName}/${repoName}/settings/secrets`;
435
436 if (!secretId) {
437 return c.redirect(`${base}?error=${encodeURIComponent("Missing secret id")}`);
438 }
439
440 const result = await deleteRepoSecret({
441 repoId: repo.id,
442 secretId,
443 });
444
445 if (!result.ok) {
446 return c.redirect(
447 `${base}?error=${encodeURIComponent(result.error)}`
448 );
449 }
450
451 try {
452 await audit({
453 userId: user.id,
454 repositoryId: repo.id,
455 action: "workflow.secret.delete",
456 targetType: "workflow_secret",
457 targetId: secretId,
458 });
459 } catch {
460 // best-effort
461 }
462
463 return c.redirect(`${base}?deleted=1`);
464 }
465);
466
54eab24Claude467/* ─────────────────────────────────────────────────────────────────────────
468 * Scoped CSS — every class prefixed `.wsec-` so this surface can't bleed
469 * into other settings pages.
470 * ───────────────────────────────────────────────────────────────────── */
471const wsecStyles = `
472 .wsec-wrap { max-width: 880px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
473
474 /* ─── Sub-nav ─── */
475 .wsec-subnav {
476 display: flex;
477 gap: 4px;
478 margin: 0 0 var(--space-4);
479 padding-bottom: var(--space-3);
480 border-bottom: 1px solid var(--border);
481 flex-wrap: wrap;
482 }
483 .wsec-subnav-link {
484 padding: 6px 12px;
485 border-radius: 8px;
486 text-decoration: none;
487 font-size: 13.5px;
488 color: var(--text-muted);
489 transition: color 120ms ease, background 120ms ease;
490 }
491 .wsec-subnav-link:hover {
492 color: var(--text);
493 text-decoration: none;
494 background: rgba(255,255,255,0.03);
495 }
496 .wsec-subnav-link.is-active {
497 background: var(--bg-secondary);
498 color: var(--text-strong);
499 font-weight: 600;
500 }
501
502 /* ─── Hero ─── */
503 .wsec-hero {
504 position: relative;
505 margin-bottom: var(--space-5);
506 padding: var(--space-5) var(--space-6);
507 background: var(--bg-elevated);
508 border: 1px solid var(--border);
509 border-radius: 16px;
510 overflow: hidden;
511 }
512 .wsec-hero::before {
513 content: '';
514 position: absolute;
515 top: 0; left: 0; right: 0;
516 height: 2px;
517 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
518 opacity: 0.7;
519 pointer-events: none;
520 }
521 .wsec-hero-orb {
522 position: absolute;
523 inset: -20% -10% auto auto;
524 width: 380px; height: 380px;
525 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
526 filter: blur(80px);
527 opacity: 0.65;
528 pointer-events: none;
529 z-index: 0;
530 }
531 .wsec-hero-inner { position: relative; z-index: 1; max-width: 640px; }
532 .wsec-eyebrow {
533 font-size: 12px;
534 color: var(--text-muted);
535 margin-bottom: var(--space-2);
536 letter-spacing: 0.02em;
537 display: inline-flex;
538 align-items: center;
539 gap: 8px;
540 }
541 .wsec-eyebrow strong {
542 color: var(--accent);
543 font-weight: 600;
544 }
545 .wsec-eyebrow-pill {
546 display: inline-flex;
547 align-items: center;
548 justify-content: center;
549 width: 18px; height: 18px;
550 border-radius: 6px;
551 background: rgba(140,109,255,0.14);
552 color: #b69dff;
553 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
554 }
555 .wsec-title {
556 font-size: clamp(26px, 4vw, 36px);
557 font-family: var(--font-display);
558 font-weight: 800;
559 letter-spacing: -0.026em;
560 line-height: 1.05;
561 margin: 0 0 var(--space-2);
562 color: var(--text-strong);
563 }
564 .wsec-title-grad {
565 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
566 -webkit-background-clip: text;
567 background-clip: text;
568 -webkit-text-fill-color: transparent;
569 color: transparent;
570 }
571 .wsec-sub {
572 font-size: 14.5px;
573 color: var(--text-muted);
574 margin: 0;
575 line-height: 1.55;
576 }
577 .wsec-sub code {
578 font-family: var(--font-mono);
579 font-size: 12.5px;
580 background: var(--bg-tertiary);
581 padding: 1px 5px;
582 border-radius: 4px;
583 color: var(--text-strong);
584 }
585
586 /* ─── Banner ─── */
587 .wsec-banner {
588 margin-bottom: var(--space-4);
589 padding: 10px 14px;
590 border-radius: 10px;
591 font-size: 13.5px;
592 border: 1px solid var(--border);
593 background: rgba(255,255,255,0.025);
594 color: var(--text);
595 display: flex;
596 align-items: center;
597 gap: 10px;
598 }
599 .wsec-banner code {
600 font-family: var(--font-mono);
601 font-size: 12.5px;
602 background: rgba(0,0,0,0.18);
603 padding: 1px 6px;
604 border-radius: 4px;
605 }
606 .wsec-banner.is-ok {
607 border-color: rgba(52,211,153,0.40);
608 background: rgba(52,211,153,0.08);
609 color: #bbf7d0;
610 }
611 .wsec-banner.is-error {
612 border-color: rgba(248,113,113,0.40);
613 background: rgba(248,113,113,0.08);
614 color: #fecaca;
615 }
616 .wsec-banner-dot {
617 width: 8px; height: 8px;
618 border-radius: 9999px;
619 background: currentColor;
620 flex-shrink: 0;
621 }
622
623 /* ─── Section cards ─── */
624 .wsec-section {
625 margin-bottom: var(--space-5);
626 background: var(--bg-elevated);
627 border: 1px solid var(--border);
628 border-radius: 14px;
629 overflow: hidden;
630 }
631 .wsec-section-head {
632 padding: var(--space-4) var(--space-5);
633 border-bottom: 1px solid var(--border);
634 display: flex;
635 align-items: flex-start;
636 justify-content: space-between;
637 gap: var(--space-3);
638 flex-wrap: wrap;
639 }
640 .wsec-section-title {
641 margin: 0;
642 font-family: var(--font-display);
643 font-size: 17px;
644 font-weight: 700;
645 letter-spacing: -0.018em;
646 color: var(--text-strong);
647 }
648 .wsec-section-sub {
649 margin: 6px 0 0;
650 font-size: 12.5px;
651 color: var(--text-muted);
652 line-height: 1.5;
653 }
654 .wsec-section-sub code {
655 font-family: var(--font-mono);
656 font-size: 11.5px;
657 background: var(--bg-tertiary);
658 padding: 1px 5px;
659 border-radius: 4px;
660 }
661 .wsec-section-body { padding: 0; }
662
663 .wsec-count-pill {
664 display: inline-flex;
665 align-items: center;
666 padding: 4px 10px;
667 border-radius: 9999px;
668 font-family: var(--font-mono);
669 font-size: 11.5px;
670 font-weight: 600;
671 background: rgba(140,109,255,0.10);
672 color: #c5b3ff;
673 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
674 letter-spacing: 0.02em;
675 }
676
677 /* ─── Secret list rows ─── */
678 .wsec-list {
679 list-style: none;
680 margin: 0;
681 padding: 0;
682 }
683 .wsec-row {
684 display: flex;
685 align-items: center;
686 justify-content: space-between;
687 gap: var(--space-3);
688 padding: var(--space-3) var(--space-5);
689 border-bottom: 1px solid var(--border);
690 transition: background 120ms ease;
691 }
692 .wsec-row:last-child { border-bottom: 0; }
693 .wsec-row:hover { background: rgba(255,255,255,0.018); }
694 .wsec-row-main { flex: 1; min-width: 0; }
695 .wsec-row-name-row {
696 display: flex;
697 align-items: center;
698 gap: 10px;
699 flex-wrap: wrap;
700 }
701 .wsec-row-name {
702 font-family: var(--font-mono);
703 font-size: 13.5px;
704 font-weight: 600;
705 color: var(--text-strong);
706 background: var(--bg-secondary);
707 padding: 3px 8px;
708 border-radius: 6px;
709 border: 1px solid var(--border-subtle);
710 }
711 .wsec-row-masked {
712 font-family: var(--font-mono);
713 font-size: 13px;
714 color: var(--text-faint);
715 letter-spacing: 1px;
716 user-select: none;
717 }
718 .wsec-row-meta {
719 margin-top: 6px;
720 font-size: 12px;
721 color: var(--text-muted);
722 display: flex;
723 align-items: center;
724 gap: 6px;
725 flex-wrap: wrap;
726 }
727 .wsec-meta-sep { color: var(--text-faint); }
728 .wsec-meta-link {
729 color: var(--accent);
730 text-decoration: none;
731 }
732 .wsec-meta-link:hover { text-decoration: underline; }
733 .wsec-meta-faint { color: var(--text-faint); }
734 .wsec-row-actions { flex-shrink: 0; }
735
736 /* ─── Empty state ─── */
737 .wsec-empty {
738 position: relative;
739 margin: var(--space-4) var(--space-5) var(--space-5);
740 padding: var(--space-6) var(--space-5);
741 border: 1px dashed var(--border-strong);
742 border-radius: 14px;
743 background: rgba(255,255,255,0.02);
744 text-align: center;
745 overflow: hidden;
746 }
747 .wsec-empty-orb {
748 position: absolute;
749 inset: -40% -10% auto auto;
750 width: 320px; height: 320px;
751 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
752 filter: blur(70px);
753 opacity: 0.55;
754 pointer-events: none;
755 z-index: 0;
756 }
757 .wsec-empty-inner { position: relative; z-index: 1; }
758 .wsec-empty-title {
759 margin: 0 0 6px;
760 font-family: var(--font-display);
761 font-size: 16px;
762 font-weight: 700;
763 color: var(--text-strong);
764 letter-spacing: -0.012em;
765 }
766 .wsec-empty-sub {
767 margin: 0 auto;
768 max-width: 460px;
769 font-size: 13px;
770 color: var(--text-muted);
771 line-height: 1.5;
772 }
773 .wsec-empty-sub code {
774 font-family: var(--font-mono);
775 font-size: 12px;
776 background: var(--bg-tertiary);
777 padding: 1px 5px;
778 border-radius: 4px;
779 color: var(--text-strong);
780 }
781
782 /* ─── Form ─── */
783 .wsec-form { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
784 .wsec-field { display: flex; flex-direction: column; gap: 6px; }
785 .wsec-field-label {
786 font-family: var(--font-mono);
787 font-size: 12.5px;
788 font-weight: 600;
789 color: var(--text-strong);
790 letter-spacing: -0.005em;
791 }
792 .wsec-input {
793 width: 100%;
794 padding: 9px 12px;
795 font-size: 13.5px;
796 color: var(--text);
797 background: var(--bg);
798 border: 1px solid var(--border-strong);
799 border-radius: 8px;
800 outline: none;
801 box-sizing: border-box;
802 transition: border-color 120ms ease, box-shadow 120ms ease;
803 font-family: inherit;
804 }
805 .wsec-input-mono { font-family: var(--font-mono); }
806 .wsec-textarea {
807 resize: vertical;
808 min-height: 110px;
809 line-height: 1.5;
810 }
811 .wsec-input:focus {
812 border-color: var(--border-focus);
813 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
814 }
815 .wsec-field-help {
816 margin: 0;
817 font-size: 11.5px;
818 color: var(--text-muted);
819 line-height: 1.45;
820 }
821 .wsec-field-help code {
822 font-family: var(--font-mono);
823 font-size: 11.5px;
824 background: var(--bg-tertiary);
825 padding: 1px 5px;
826 border-radius: 4px;
827 }
828 .wsec-form-actions { display: flex; justify-content: flex-end; }
829
830 /* ─── Buttons ─── */
831 .wsec-btn {
832 appearance: none;
833 border: 1px solid var(--border-strong);
834 background: var(--bg-secondary);
835 color: var(--text);
836 padding: 8px 14px;
837 border-radius: 8px;
838 font-family: inherit;
839 font-size: 13px;
840 font-weight: 500;
841 cursor: pointer;
842 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease, color 150ms ease;
843 text-decoration: none;
844 display: inline-flex;
845 align-items: center;
846 gap: 6px;
847 }
848 .wsec-btn:hover {
849 border-color: var(--border-focus);
850 background: rgba(255,255,255,0.04);
851 transform: translateY(-1px);
852 }
853 .wsec-btn-primary {
854 border-color: rgba(140,109,255,0.45);
855 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
856 color: var(--text-strong);
857 padding: 10px 18px;
858 font-size: 13.5px;
859 }
860 .wsec-btn-primary:hover {
861 border-color: rgba(140,109,255,0.65);
862 background: linear-gradient(135deg, rgba(140,109,255,0.28), rgba(54,197,214,0.20));
863 }
864 .wsec-btn-danger {
865 border-color: rgba(248,113,113,0.32);
866 color: #fecaca;
867 }
868 .wsec-btn-danger:hover {
869 border-color: rgba(248,113,113,0.55);
870 background: rgba(248,113,113,0.10);
871 color: #fee2e2;
872 }
873
874 @media (max-width: 640px) {
875 .wsec-row { flex-direction: column; align-items: flex-start; }
876 .wsec-row-actions { width: 100%; }
877 .wsec-row-actions form { width: 100%; }
878 .wsec-row-actions .wsec-btn { width: 100%; justify-content: center; }
879 }
880`;
881
abfa9adClaude882export default workflowSecretsRoutes;