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.tsxBlame1156 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";
f5b9ef5Claude35import { getDefaultBranch, getTree, getBlob } from "../git/repository";
abfa9adClaude36
37const workflowSecretsRoutes = new Hono<AuthEnv>();
38
39workflowSecretsRoutes.use("*", softAuth);
40
41const SECRET_NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
42const MAX_NAME_LEN = 100;
43const MAX_VALUE_LEN = 32768;
44
f5b9ef5Claude45/**
46 * Count how many `.gluecron/workflows/*.yml` files reference a given
47 * secret name via `${{ secrets.NAME }}`. Returns a map of secretName
48 * -> count (number of workflow files that contain at least one reference).
49 * Returns an empty map on any error so the UI degrades gracefully.
50 */
51async function countSecretUsagesInWorkflows(
52 ownerName: string,
53 repoName: string,
54 secretNames: string[]
55): Promise<Map<string, number>> {
56 const counts = new Map<string, number>(secretNames.map((n) => [n, 0]));
57 if (secretNames.length === 0) return counts;
58
59 try {
60 const branch = await getDefaultBranch(ownerName, repoName);
61 if (!branch) return counts;
62
63 // List .gluecron/workflows/ directory
64 const entries = await getTree(ownerName, repoName, branch, ".gluecron/workflows");
65 const ymlFiles = entries.filter(
66 (e) => e.type === "blob" && (e.name.endsWith(".yml") || e.name.endsWith(".yaml"))
67 );
68
69 for (const file of ymlFiles) {
70 const blob = await getBlob(
71 ownerName,
72 repoName,
73 branch,
74 `.gluecron/workflows/${file.name}`
75 );
76 if (!blob || blob.isBinary || !blob.content) continue;
77 const content = blob.content;
78 for (const name of secretNames) {
79 // Match ${{ secrets.NAME }} with optional whitespace
80 const pattern = new RegExp(
81 `\\$\\{\\{\\s*secrets\\.${name}\\s*\\}\\}`,
82 "g"
83 );
84 if (pattern.test(content)) {
85 counts.set(name, (counts.get(name) ?? 0) + 1);
86 }
87 }
88 }
89 } catch {
90 // Best-effort — return whatever we have
91 }
92
93 return counts;
94}
95
abfa9adClaude96/** Sub-nav shown under the main RepoNav for repo settings pages. */
97function SettingsSubNav({
98 owner,
99 repo,
100 active,
101}: {
102 owner: string;
103 repo: string;
104 active: "general" | "collaborators" | "webhooks" | "secrets";
105}) {
106 const link = (
107 href: string,
108 label: string,
109 key: "general" | "collaborators" | "webhooks" | "secrets"
110 ) => (
111 <a
112 href={href}
54eab24Claude113 class={
114 "wsec-subnav-link" + (active === key ? " is-active" : "")
abfa9adClaude115 }
116 >
117 {label}
118 </a>
119 );
120 return (
54eab24Claude121 <nav class="wsec-subnav" aria-label="Repository settings">
abfa9adClaude122 {link(`/${owner}/${repo}/settings`, "General", "general")}
123 {link(
124 `/${owner}/${repo}/settings/collaborators`,
125 "Collaborators",
126 "collaborators"
127 )}
128 {link(
129 `/${owner}/${repo}/settings/webhooks`,
130 "Webhooks",
131 "webhooks"
132 )}
133 {link(
134 `/${owner}/${repo}/settings/secrets`,
135 "Secrets",
136 "secrets"
137 )}
54eab24Claude138 </nav>
abfa9adClaude139 );
140}
141
54eab24Claude142/**
143 * Best-effort masked preview of a secret. We never have the plaintext on
144 * disk, so this is a stable visual placeholder (always 12 bullets). The
145 * dot count is deliberately constant so the list doesn't leak length.
146 */
147const MASKED_PLACEHOLDER = "••••••••••••";
148
f5b9ef5Claude149
150const wsecScript = `
151(function () {
152 var NAME_RE = /^[A-Z_][A-Z0-9_]*$/;
153
154 /**
155 * wsecTestName — called oninput on the name field and onclick on the
156 * Test button. Toggles .is-ok / .is-error CSS on the button and updates
157 * the hint text below the field.
158 *
159 * @param {HTMLInputElement} el the name <input> element
160 */
161 window.wsecTestName = function wsecTestName(el) {
162 var val = (el.value || '').trim();
163 var btn = document.getElementById('wsec-test-btn');
164 var hint = document.getElementById('wsec-name-hint');
165 if (!btn || !hint) return;
166
167 // Reset
168 btn.classList.remove('is-ok', 'is-error');
169
170 if (!val) {
171 hint.innerHTML = 'Referenced in YAML as <code>\$\{{ secrets.YOUR_NAME }}</code>.';
172 hint.className = 'wsec-field-help';
173 return;
174 }
175
176 if (NAME_RE.test(val)) {
177 btn.classList.add('is-ok');
178 hint.innerHTML = '<span class="wsec-name-hint-ok">&#10003; Valid name.</span> Referenced as <code>\$\{{ secrets.' + val + ' }}</code>.';
179 hint.className = 'wsec-field-help';
180 } else {
181 btn.classList.add('is-error');
182 var msg = 'Invalid name.';
183 if (!/^[A-Z_]/.test(val)) {
184 msg = 'Must start with an uppercase letter or underscore (A-Z or _).';
185 } else if (/[a-z]/.test(val)) {
186 msg = 'Lowercase letters are not allowed — use uppercase only.';
187 } else if (/[^A-Z0-9_]/.test(val)) {
188 msg = 'Only uppercase letters, digits (0-9), and underscores are allowed.';
189 }
190 hint.innerHTML = '<span class="wsec-name-hint-err">&#10007; ' + msg + '</span>';
191 hint.className = 'wsec-field-help';
192 }
193 };
194
195 // Wire up on DOMContentLoaded so the element definitely exists.
196 document.addEventListener('DOMContentLoaded', function () {
197 var nameInput = document.getElementById('secret-name');
198 if (nameInput) {
199 nameInput.addEventListener('input', function () { window.wsecTestName(nameInput); });
200 }
201 });
202}());
203`;
204
abfa9adClaude205// ─── GET: List + add form ───────────────────────────────────────────────────
206
207workflowSecretsRoutes.get(
208 "/:owner/:repo/settings/secrets",
209 requireAuth,
210 requireRepoAccess("admin"),
211 async (c) => {
212 const { owner: ownerName, repo: repoName } = c.req.param();
213 const user = c.get("user")!;
214 const repo = c.get("repository") as { id: string } | undefined;
215
216 const added = c.req.query("added");
217 const deleted = c.req.query("deleted");
218 const error = c.req.query("error");
219
220 if (!repo) {
221 return c.notFound();
222 }
223
224 const result = await listRepoSecrets(repo.id);
225 const secrets = result.ok ? result.secrets : [];
226 const loadError = result.ok ? null : result.error;
227
228 // Resolve createdBy user ids -> usernames for display/linking.
229 const creatorIds = Array.from(
230 new Set(secrets.map((s) => s.createdBy).filter(Boolean) as string[])
231 );
232 const creatorRows = creatorIds.length
233 ? await db
234 .select({ id: users.id, username: users.username })
235 .from(users)
236 .where(inArray(users.id, creatorIds))
237 : [];
238 const creatorMap = new Map(creatorRows.map((r) => [r.id, r.username]));
239
f5b9ef5Claude240 // Count workflow usages per secret (best-effort, never throws).
241 const usageCounts = await countSecretUsagesInWorkflows(
242 ownerName,
243 repoName,
244 secrets.map((s) => s.name)
245 );
246
abfa9adClaude247 return c.html(
248 <Layout title={`Secrets — ${ownerName}/${repoName}`} user={user}>
249 <RepoHeader owner={ownerName} repo={repoName} currentUser={user.username} />
250 <RepoNav owner={ownerName} repo={repoName} active="settings" />
54eab24Claude251 <style dangerouslySetInnerHTML={{ __html: wsecStyles }} />
252 <div class="wsec-wrap">
abfa9adClaude253 <SettingsSubNav
254 owner={ownerName}
255 repo={repoName}
256 active="secrets"
257 />
258
54eab24Claude259 {/* ─── Hero ─── */}
260 <section class="wsec-hero">
261 <div class="wsec-hero-orb" aria-hidden="true" />
262 <div class="wsec-hero-inner">
263 <div class="wsec-eyebrow">
264 <span class="wsec-eyebrow-pill" aria-hidden="true">
265 <svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
266 <rect x="3" y="11" width="18" height="11" rx="2" />
267 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
268 </svg>
269 </span>
270 Workflow secrets · <strong>{ownerName}/{repoName}</strong>
271 </div>
272 <h1 class="wsec-title">
273 Secrets, <span class="wsec-title-grad">encrypted at rest.</span>
274 </h1>
275 <p class="wsec-sub">
276 Reference them in YAML as{" "}
277 <code>{"${{ secrets.NAME }}"}</code>. Values are write-only —
278 after saving, only the name is visible. They're never printed
279 to workflow logs.
280 </p>
281 </div>
282 </section>
abfa9adClaude283
54eab24Claude284 {/* ─── Flash banners ─── */}
abfa9adClaude285 {added && (
54eab24Claude286 <div class="wsec-banner is-ok">
287 <span class="wsec-banner-dot" aria-hidden="true" />
abfa9adClaude288 Secret <code>{decodeURIComponent(added)}</code> saved.
54eab24Claude289 </div>
290 )}
291 {deleted && (
292 <div class="wsec-banner is-ok">
293 <span class="wsec-banner-dot" aria-hidden="true" />
294 Secret deleted.
295 </div>
abfa9adClaude296 )}
297 {error && (
54eab24Claude298 <div class="wsec-banner is-error">
299 <span class="wsec-banner-dot" aria-hidden="true" />
300 {decodeURIComponent(error)}
301 </div>
abfa9adClaude302 )}
303 {loadError && (
54eab24Claude304 <div class="wsec-banner is-error">
305 <span class="wsec-banner-dot" aria-hidden="true" />
abfa9adClaude306 Could not load secrets: {loadError}
54eab24Claude307 </div>
abfa9adClaude308 )}
309
54eab24Claude310 {/* ─── Secrets list ─── */}
311 <section class="wsec-section" aria-labelledby="wsec-list-h">
312 <header class="wsec-section-head">
313 <div>
314 <h2 class="wsec-section-title" id="wsec-list-h">
315 Stored secrets
316 </h2>
317 <p class="wsec-section-sub">
318 {secrets.length === 0
319 ? "Add your first secret below."
320 : `${secrets.length} secret${secrets.length === 1 ? "" : "s"} available to workflows in this repo.`}
321 </p>
abfa9adClaude322 </div>
54eab24Claude323 <span class="wsec-count-pill">
324 {secrets.length} / unlimited
325 </span>
326 </header>
327 <div class="wsec-section-body">
328 {secrets.length === 0 ? (
329 <div class="wsec-empty">
330 <div class="wsec-empty-orb" aria-hidden="true" />
331 <div class="wsec-empty-inner">
f5b9ef5Claude332 <div class="wsec-empty-icon" aria-hidden="true">
333 <svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
334 <rect x="3" y="11" width="18" height="11" rx="2" />
335 <path d="M7 11V7a5 5 0 0 1 10 0v4" />
336 </svg>
337 </div>
338 <p class="wsec-empty-title">No secrets configured yet.</p>
54eab24Claude339 <p class="wsec-empty-sub">
f5b9ef5Claude340 Secrets let you store sensitive values — API keys, deploy tokens,
341 and passwords — securely encrypted in the database. Reference them
342 in any workflow file with{" "}
343 <code>{"${{ secrets.YOUR_NAME }}"}</code>. They're never
344 printed to logs and only visible to workflows at runtime.
54eab24Claude345 </p>
f5b9ef5Claude346 <div class="wsec-empty-steps">
347 <div class="wsec-empty-step">
348 <span class="wsec-empty-step-num">1</span>
349 <span>Enter a name like <code>DEPLOY_TOKEN</code> in the form below (uppercase letters, digits, underscores).</span>
350 </div>
351 <div class="wsec-empty-step">
352 <span class="wsec-empty-step-num">2</span>
353 <span>Paste the secret value — it's encrypted with AES-256-GCM before touching the database.</span>
354 </div>
355 <div class="wsec-empty-step">
356 <span class="wsec-empty-step-num">3</span>
357 <span>Use <code>{"${{ secrets.DEPLOY_TOKEN }}"}</code> in your <code>.gluecron/workflows/*.yml</code> files.</span>
358 </div>
359 </div>
54eab24Claude360 </div>
361 </div>
362 ) : (
363 <ul class="wsec-list">
abfa9adClaude364 {secrets.map((s) => {
365 const creator = s.createdBy
366 ? creatorMap.get(s.createdBy)
367 : null;
54eab24Claude368 const created = s.createdAt
369 ? formatRelative(s.createdAt)
370 : "—";
f5b9ef5Claude371 const wfCount = usageCounts.get(s.name) ?? 0;
abfa9adClaude372 return (
54eab24Claude373 <li class="wsec-row">
374 <div class="wsec-row-main">
375 <div class="wsec-row-name-row">
376 <code class="wsec-row-name">{s.name}</code>
377 <span class="wsec-row-masked" aria-label="value hidden">
378 {MASKED_PLACEHOLDER}
abfa9adClaude379 </span>
f5b9ef5Claude380 <span
381 class={`wsec-usage-badge${wfCount === 0 ? " is-unused" : ""}`}
382 title={wfCount === 0
383 ? "Not referenced in any workflow file"
384 : `Referenced in ${wfCount} workflow file${wfCount === 1 ? "" : "s"}`}
385 >
386 <svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
387 <polyline points="22 12 18 12 15 21 9 3 6 12 2 12" />
388 </svg>
389 {wfCount === 0
390 ? "unused"
391 : `${wfCount} workflow${wfCount === 1 ? "" : "s"}`}
392 </span>
54eab24Claude393 </div>
394 <div class="wsec-row-meta">
395 <span class="wsec-meta-item">
396 Added {created}
397 </span>
398 <span class="wsec-meta-sep" aria-hidden="true">·</span>
399 <span class="wsec-meta-item">
400 by{" "}
401 {creator ? (
402 <a
403 href={`/${creator}`}
404 class="wsec-meta-link"
405 >
406 {creator}
407 </a>
408 ) : (
409 <span class="wsec-meta-faint">unknown</span>
410 )}
411 </span>
412 </div>
413 </div>
414 <div class="wsec-row-actions">
abfa9adClaude415 <form
416 method="post"
417 action={`/${ownerName}/${repoName}/settings/secrets/${s.id}/delete`}
54eab24Claude418 onsubmit={`return confirm('Revoke secret ${s.name}? Workflow steps that reference it will fail until you add it again.')`}
abfa9adClaude419 >
54eab24Claude420 <button
abfa9adClaude421 type="submit"
54eab24Claude422 class="wsec-btn wsec-btn-danger"
abfa9adClaude423 >
54eab24Claude424 Revoke
425 </button>
abfa9adClaude426 </form>
54eab24Claude427 </div>
428 </li>
abfa9adClaude429 );
430 })}
54eab24Claude431 </ul>
432 )}
abfa9adClaude433 </div>
54eab24Claude434 </section>
435
436 {/* ─── Add new ─── */}
437 <section class="wsec-section" aria-labelledby="wsec-add-h">
438 <header class="wsec-section-head">
439 <div>
440 <h2 class="wsec-section-title" id="wsec-add-h">
441 Add a new secret
442 </h2>
443 <p class="wsec-section-sub">
444 Names must be uppercase letters, digits, and underscores,
445 and cannot start with a digit. Adding a secret with an
446 existing name replaces the stored value.
447 </p>
448 </div>
449 </header>
450 <div class="wsec-section-body">
451 <form
452 method="post"
453 action={`/${ownerName}/${repoName}/settings/secrets`}
454 class="wsec-form"
455 >
456 <div class="wsec-field">
457 <label class="wsec-field-label" for="secret-name">
458 Name
459 </label>
f5b9ef5Claude460 <div class="wsec-input-row">
461 <input
462 type="text"
463 id="secret-name"
464 name="name"
465 required
466 pattern="[A-Z_][A-Z0-9_]*"
467 maxlength={MAX_NAME_LEN}
468 placeholder="DEPLOY_TOKEN"
469 autocomplete="off"
470 class="wsec-input wsec-input-mono"
471 title="Uppercase letters, digits, and underscores; cannot start with a digit"
472 oninput="wsecTestName(this)"
473 />
474 <button
475 type="button"
476 class="wsec-btn wsec-btn-test"
477 id="wsec-test-btn"
478 onclick="wsecTestName(document.getElementById('secret-name'))"
479 title="Validate the name format"
480 >
481 Test
482 </button>
483 </div>
484 <p class="wsec-field-help" id="wsec-name-hint">
54eab24Claude485 Referenced in YAML as{" "}
486 <code>{"${{ secrets.YOUR_NAME }}"}</code>.
487 </p>
488 </div>
489 <div class="wsec-field">
490 <label class="wsec-field-label" for="secret-value">
491 Value
492 </label>
493 <textarea
494 id="secret-value"
495 name="value"
496 required
497 rows={5}
498 maxlength={MAX_VALUE_LEN}
499 placeholder="Paste secret value"
500 autocomplete="off"
501 spellcheck={false}
502 class="wsec-input wsec-input-mono wsec-textarea"
503 />
504 <p class="wsec-field-help">
505 Encrypted with AES-256-GCM before the row hits the
506 database. We never write it to logs.
507 </p>
508 </div>
509 <div class="wsec-form-actions">
510 <button
511 type="submit"
512 class="wsec-btn wsec-btn-primary"
513 >
514 Add secret
515 </button>
516 </div>
517 </form>
abfa9adClaude518 </div>
54eab24Claude519 </section>
520 </div>
f5b9ef5Claude521 <script dangerouslySetInnerHTML={{ __html: wsecScript }} />
abfa9adClaude522 </Layout>
523 );
524 }
525);
526
527// ─── POST: Create / update ──────────────────────────────────────────────────
528
529workflowSecretsRoutes.post(
530 "/:owner/:repo/settings/secrets",
531 requireAuth,
532 requireRepoAccess("admin"),
533 async (c) => {
534 const { owner: ownerName, repo: repoName } = c.req.param();
535 const user = c.get("user")!;
536 const repo = c.get("repository") as { id: string } | undefined;
537 if (!repo) return c.notFound();
538
539 const body = await c.req.parseBody();
540 const name = String(body.name || "").trim();
541 const value = typeof body.value === "string" ? body.value : "";
542
543 const base = `/${ownerName}/${repoName}/settings/secrets`;
544 const fail = (msg: string) =>
545 c.redirect(`${base}?error=${encodeURIComponent(msg)}`);
546
547 if (!name) return fail("Name is required");
548 if (name.length > MAX_NAME_LEN)
549 return fail(`Name must be ≤ ${MAX_NAME_LEN} characters`);
550 if (!SECRET_NAME_RE.test(name))
551 return fail(
552 "Name must be uppercase letters, digits, and underscores, and cannot start with a digit"
553 );
554 if (!value) return fail("Value is required");
555 if (value.length > MAX_VALUE_LEN)
556 return fail(`Value must be ≤ ${MAX_VALUE_LEN} characters`);
557
558 const result = await upsertRepoSecret({
559 repoId: repo.id,
560 name,
561 value,
562 createdBy: user.id,
563 });
564
565 if (!result.ok) return fail(result.error);
566
567 // Best-effort audit — swallow any error so it never breaks the redirect.
568 try {
569 await audit({
570 userId: user.id,
571 repositoryId: repo.id,
572 action: "workflow.secret.create",
573 targetType: "workflow_secret",
574 targetId: result.id,
575 metadata: { name },
576 });
577 } catch {
578 // audit() already swallows errors, but guard anyway.
579 }
580
581 return c.redirect(`${base}?added=${encodeURIComponent(name)}`);
582 }
583);
584
585// ─── POST: Delete ───────────────────────────────────────────────────────────
586
587workflowSecretsRoutes.post(
588 "/:owner/:repo/settings/secrets/:secretId/delete",
589 requireAuth,
590 requireRepoAccess("admin"),
591 async (c) => {
592 const { owner: ownerName, repo: repoName } = c.req.param();
593 const user = c.get("user")!;
594 const repo = c.get("repository") as { id: string } | undefined;
595 if (!repo) return c.notFound();
596
597 const secretId = c.req.param("secretId");
598 const base = `/${ownerName}/${repoName}/settings/secrets`;
599
600 if (!secretId) {
601 return c.redirect(`${base}?error=${encodeURIComponent("Missing secret id")}`);
602 }
603
604 const result = await deleteRepoSecret({
605 repoId: repo.id,
606 secretId,
607 });
608
609 if (!result.ok) {
610 return c.redirect(
611 `${base}?error=${encodeURIComponent(result.error)}`
612 );
613 }
614
615 try {
616 await audit({
617 userId: user.id,
618 repositoryId: repo.id,
619 action: "workflow.secret.delete",
620 targetType: "workflow_secret",
621 targetId: secretId,
622 });
623 } catch {
624 // best-effort
625 }
626
627 return c.redirect(`${base}?deleted=1`);
628 }
629);
630
54eab24Claude631/* ─────────────────────────────────────────────────────────────────────────
632 * Scoped CSS — every class prefixed `.wsec-` so this surface can't bleed
633 * into other settings pages.
634 * ───────────────────────────────────────────────────────────────────── */
635const wsecStyles = `
eed4684Claude636 .wsec-wrap { max-width: 1200px; margin: 0 auto; padding: var(--space-5) var(--space-4); }
54eab24Claude637
638 /* ─── Sub-nav ─── */
639 .wsec-subnav {
640 display: flex;
641 gap: 4px;
642 margin: 0 0 var(--space-4);
643 padding-bottom: var(--space-3);
644 border-bottom: 1px solid var(--border);
645 flex-wrap: wrap;
646 }
647 .wsec-subnav-link {
648 padding: 6px 12px;
649 border-radius: 8px;
650 text-decoration: none;
651 font-size: 13.5px;
652 color: var(--text-muted);
653 transition: color 120ms ease, background 120ms ease;
654 }
655 .wsec-subnav-link:hover {
656 color: var(--text);
657 text-decoration: none;
658 background: rgba(255,255,255,0.03);
659 }
660 .wsec-subnav-link.is-active {
661 background: var(--bg-secondary);
662 color: var(--text-strong);
663 font-weight: 600;
664 }
665
666 /* ─── Hero ─── */
667 .wsec-hero {
668 position: relative;
669 margin-bottom: var(--space-5);
670 padding: var(--space-5) var(--space-6);
671 background: var(--bg-elevated);
672 border: 1px solid var(--border);
673 border-radius: 16px;
674 overflow: hidden;
675 }
676 .wsec-hero::before {
677 content: '';
678 position: absolute;
679 top: 0; left: 0; right: 0;
680 height: 2px;
681 background: linear-gradient(90deg, transparent 0%, #8c6dff 30%, #36c5d6 70%, transparent 100%);
682 opacity: 0.7;
683 pointer-events: none;
684 }
685 .wsec-hero-orb {
686 position: absolute;
687 inset: -20% -10% auto auto;
688 width: 380px; height: 380px;
689 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
690 filter: blur(80px);
691 opacity: 0.65;
692 pointer-events: none;
693 z-index: 0;
694 }
695 .wsec-hero-inner { position: relative; z-index: 1; max-width: 640px; }
696 .wsec-eyebrow {
697 font-size: 12px;
698 color: var(--text-muted);
699 margin-bottom: var(--space-2);
700 letter-spacing: 0.02em;
701 display: inline-flex;
702 align-items: center;
703 gap: 8px;
704 }
705 .wsec-eyebrow strong {
706 color: var(--accent);
707 font-weight: 600;
708 }
709 .wsec-eyebrow-pill {
710 display: inline-flex;
711 align-items: center;
712 justify-content: center;
713 width: 18px; height: 18px;
714 border-radius: 6px;
715 background: rgba(140,109,255,0.14);
716 color: #b69dff;
717 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.35);
718 }
719 .wsec-title {
720 font-size: clamp(26px, 4vw, 36px);
721 font-family: var(--font-display);
722 font-weight: 800;
723 letter-spacing: -0.026em;
724 line-height: 1.05;
725 margin: 0 0 var(--space-2);
726 color: var(--text-strong);
727 }
728 .wsec-title-grad {
729 background-image: linear-gradient(135deg, #a48bff 0%, #8c6dff 50%, #36c5d6 100%);
730 -webkit-background-clip: text;
731 background-clip: text;
732 -webkit-text-fill-color: transparent;
733 color: transparent;
734 }
735 .wsec-sub {
736 font-size: 14.5px;
737 color: var(--text-muted);
738 margin: 0;
739 line-height: 1.55;
740 }
741 .wsec-sub code {
742 font-family: var(--font-mono);
743 font-size: 12.5px;
744 background: var(--bg-tertiary);
745 padding: 1px 5px;
746 border-radius: 4px;
747 color: var(--text-strong);
748 }
749
750 /* ─── Banner ─── */
751 .wsec-banner {
752 margin-bottom: var(--space-4);
753 padding: 10px 14px;
754 border-radius: 10px;
755 font-size: 13.5px;
756 border: 1px solid var(--border);
757 background: rgba(255,255,255,0.025);
758 color: var(--text);
759 display: flex;
760 align-items: center;
761 gap: 10px;
762 }
763 .wsec-banner code {
764 font-family: var(--font-mono);
765 font-size: 12.5px;
766 background: rgba(0,0,0,0.18);
767 padding: 1px 6px;
768 border-radius: 4px;
769 }
770 .wsec-banner.is-ok {
771 border-color: rgba(52,211,153,0.40);
772 background: rgba(52,211,153,0.08);
773 color: #bbf7d0;
774 }
775 .wsec-banner.is-error {
776 border-color: rgba(248,113,113,0.40);
777 background: rgba(248,113,113,0.08);
778 color: #fecaca;
779 }
780 .wsec-banner-dot {
781 width: 8px; height: 8px;
782 border-radius: 9999px;
783 background: currentColor;
784 flex-shrink: 0;
785 }
786
787 /* ─── Section cards ─── */
788 .wsec-section {
789 margin-bottom: var(--space-5);
790 background: var(--bg-elevated);
791 border: 1px solid var(--border);
792 border-radius: 14px;
793 overflow: hidden;
794 }
795 .wsec-section-head {
796 padding: var(--space-4) var(--space-5);
797 border-bottom: 1px solid var(--border);
798 display: flex;
799 align-items: flex-start;
800 justify-content: space-between;
801 gap: var(--space-3);
802 flex-wrap: wrap;
803 }
804 .wsec-section-title {
805 margin: 0;
806 font-family: var(--font-display);
807 font-size: 17px;
808 font-weight: 700;
809 letter-spacing: -0.018em;
810 color: var(--text-strong);
811 }
812 .wsec-section-sub {
813 margin: 6px 0 0;
814 font-size: 12.5px;
815 color: var(--text-muted);
816 line-height: 1.5;
817 }
818 .wsec-section-sub code {
819 font-family: var(--font-mono);
820 font-size: 11.5px;
821 background: var(--bg-tertiary);
822 padding: 1px 5px;
823 border-radius: 4px;
824 }
825 .wsec-section-body { padding: 0; }
826
827 .wsec-count-pill {
828 display: inline-flex;
829 align-items: center;
830 padding: 4px 10px;
831 border-radius: 9999px;
832 font-family: var(--font-mono);
833 font-size: 11.5px;
834 font-weight: 600;
835 background: rgba(140,109,255,0.10);
836 color: #c5b3ff;
837 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.28);
838 letter-spacing: 0.02em;
839 }
840
841 /* ─── Secret list rows ─── */
842 .wsec-list {
843 list-style: none;
844 margin: 0;
845 padding: 0;
846 }
847 .wsec-row {
848 display: flex;
849 align-items: center;
850 justify-content: space-between;
851 gap: var(--space-3);
852 padding: var(--space-3) var(--space-5);
853 border-bottom: 1px solid var(--border);
854 transition: background 120ms ease;
855 }
856 .wsec-row:last-child { border-bottom: 0; }
857 .wsec-row:hover { background: rgba(255,255,255,0.018); }
858 .wsec-row-main { flex: 1; min-width: 0; }
859 .wsec-row-name-row {
860 display: flex;
861 align-items: center;
862 gap: 10px;
863 flex-wrap: wrap;
864 }
865 .wsec-row-name {
866 font-family: var(--font-mono);
867 font-size: 13.5px;
868 font-weight: 600;
869 color: var(--text-strong);
870 background: var(--bg-secondary);
871 padding: 3px 8px;
872 border-radius: 6px;
873 border: 1px solid var(--border-subtle);
874 }
875 .wsec-row-masked {
876 font-family: var(--font-mono);
877 font-size: 13px;
878 color: var(--text-faint);
879 letter-spacing: 1px;
880 user-select: none;
881 }
882 .wsec-row-meta {
883 margin-top: 6px;
884 font-size: 12px;
885 color: var(--text-muted);
886 display: flex;
887 align-items: center;
888 gap: 6px;
889 flex-wrap: wrap;
890 }
891 .wsec-meta-sep { color: var(--text-faint); }
892 .wsec-meta-link {
893 color: var(--accent);
894 text-decoration: none;
895 }
896 .wsec-meta-link:hover { text-decoration: underline; }
897 .wsec-meta-faint { color: var(--text-faint); }
898 .wsec-row-actions { flex-shrink: 0; }
899
900 /* ─── Empty state ─── */
901 .wsec-empty {
902 position: relative;
903 margin: var(--space-4) var(--space-5) var(--space-5);
904 padding: var(--space-6) var(--space-5);
905 border: 1px dashed var(--border-strong);
906 border-radius: 14px;
907 background: rgba(255,255,255,0.02);
908 text-align: center;
909 overflow: hidden;
910 }
911 .wsec-empty-orb {
912 position: absolute;
913 inset: -40% -10% auto auto;
914 width: 320px; height: 320px;
915 background: radial-gradient(circle, rgba(140,109,255,0.20), rgba(54,197,214,0.10) 45%, transparent 70%);
916 filter: blur(70px);
917 opacity: 0.55;
918 pointer-events: none;
919 z-index: 0;
920 }
921 .wsec-empty-inner { position: relative; z-index: 1; }
922 .wsec-empty-title {
923 margin: 0 0 6px;
924 font-family: var(--font-display);
925 font-size: 16px;
926 font-weight: 700;
927 color: var(--text-strong);
928 letter-spacing: -0.012em;
929 }
930 .wsec-empty-sub {
931 margin: 0 auto;
932 max-width: 460px;
933 font-size: 13px;
934 color: var(--text-muted);
935 line-height: 1.5;
936 }
937 .wsec-empty-sub code {
938 font-family: var(--font-mono);
939 font-size: 12px;
940 background: var(--bg-tertiary);
941 padding: 1px 5px;
942 border-radius: 4px;
943 color: var(--text-strong);
944 }
945
f5b9ef5Claude946 /* ─── Usage badge ─── */
947 .wsec-usage-badge {
948 display: inline-flex;
949 align-items: center;
950 gap: 4px;
951 padding: 2px 8px;
952 border-radius: 9999px;
953 font-family: var(--font-mono);
954 font-size: 11px;
955 font-weight: 500;
956 background: rgba(52,211,153,0.10);
957 color: #6ee7b7;
958 box-shadow: inset 0 0 0 1px rgba(52,211,153,0.25);
959 letter-spacing: 0.01em;
960 cursor: default;
961 }
962 .wsec-usage-badge.is-unused {
963 background: rgba(100,116,139,0.12);
964 color: var(--text-faint);
965 box-shadow: inset 0 0 0 1px rgba(100,116,139,0.25);
966 }
967
968 /* ─── Empty state steps ─── */
969 .wsec-empty-icon {
970 margin: 0 auto var(--space-3);
971 width: 48px; height: 48px;
972 border-radius: 14px;
973 background: rgba(140,109,255,0.10);
974 border: 1px solid rgba(140,109,255,0.20);
975 display: flex;
976 align-items: center;
977 justify-content: center;
978 color: #b69dff;
979 }
980 .wsec-empty-steps {
981 margin-top: var(--space-4);
982 display: flex;
983 flex-direction: column;
984 gap: 10px;
985 text-align: left;
986 max-width: 480px;
987 margin-left: auto;
988 margin-right: auto;
989 }
990 .wsec-empty-step {
991 display: flex;
992 align-items: flex-start;
993 gap: 10px;
994 font-size: 12.5px;
995 color: var(--text-muted);
996 line-height: 1.5;
997 }
998 .wsec-empty-step code {
999 font-family: var(--font-mono);
1000 font-size: 11.5px;
1001 background: var(--bg-tertiary);
1002 padding: 1px 5px;
1003 border-radius: 4px;
1004 color: var(--text-strong);
1005 }
1006 .wsec-empty-step-num {
1007 flex-shrink: 0;
1008 width: 20px; height: 20px;
1009 border-radius: 9999px;
1010 background: rgba(140,109,255,0.15);
1011 color: #c5b3ff;
1012 font-family: var(--font-mono);
1013 font-size: 11px;
1014 font-weight: 700;
1015 display: inline-flex;
1016 align-items: center;
1017 justify-content: center;
1018 box-shadow: inset 0 0 0 1px rgba(140,109,255,0.25);
1019 margin-top: 1px;
1020 }
1021
54eab24Claude1022 /* ─── Form ─── */
1023 .wsec-form { padding: var(--space-5); display: flex; flex-direction: column; gap: var(--space-4); }
1024 .wsec-field { display: flex; flex-direction: column; gap: 6px; }
f5b9ef5Claude1025 .wsec-input-row {
1026 display: flex;
1027 gap: 8px;
1028 align-items: stretch;
1029 }
1030 .wsec-input-row .wsec-input {
1031 flex: 1;
1032 min-width: 0;
1033 }
54eab24Claude1034 .wsec-field-label {
1035 font-family: var(--font-mono);
1036 font-size: 12.5px;
1037 font-weight: 600;
1038 color: var(--text-strong);
1039 letter-spacing: -0.005em;
1040 }
1041 .wsec-input {
1042 width: 100%;
1043 padding: 9px 12px;
1044 font-size: 13.5px;
1045 color: var(--text);
1046 background: var(--bg);
1047 border: 1px solid var(--border-strong);
1048 border-radius: 8px;
1049 outline: none;
1050 box-sizing: border-box;
1051 transition: border-color 120ms ease, box-shadow 120ms ease;
1052 font-family: inherit;
1053 }
1054 .wsec-input-mono { font-family: var(--font-mono); }
1055 .wsec-textarea {
1056 resize: vertical;
1057 min-height: 110px;
1058 line-height: 1.5;
1059 }
1060 .wsec-input:focus {
1061 border-color: var(--border-focus);
1062 box-shadow: 0 0 0 3px rgba(140,109,255,0.18);
1063 }
1064 .wsec-field-help {
1065 margin: 0;
1066 font-size: 11.5px;
1067 color: var(--text-muted);
1068 line-height: 1.45;
1069 }
1070 .wsec-field-help code {
1071 font-family: var(--font-mono);
1072 font-size: 11.5px;
1073 background: var(--bg-tertiary);
1074 padding: 1px 5px;
1075 border-radius: 4px;
1076 }
1077 .wsec-form-actions { display: flex; justify-content: flex-end; }
1078
1079 /* ─── Buttons ─── */
1080 .wsec-btn {
1081 appearance: none;
1082 border: 1px solid var(--border-strong);
1083 background: var(--bg-secondary);
1084 color: var(--text);
1085 padding: 8px 14px;
1086 border-radius: 8px;
1087 font-family: inherit;
1088 font-size: 13px;
1089 font-weight: 500;
1090 cursor: pointer;
1091 transition: border-color 150ms ease, background 150ms ease, transform 150ms ease, color 150ms ease;
1092 text-decoration: none;
1093 display: inline-flex;
1094 align-items: center;
1095 gap: 6px;
1096 }
1097 .wsec-btn:hover {
1098 border-color: var(--border-focus);
1099 background: rgba(255,255,255,0.04);
1100 transform: translateY(-1px);
1101 }
1102 .wsec-btn-primary {
1103 border-color: rgba(140,109,255,0.45);
1104 background: linear-gradient(135deg, rgba(140,109,255,0.20), rgba(54,197,214,0.14));
1105 color: var(--text-strong);
1106 padding: 10px 18px;
1107 font-size: 13.5px;
1108 }
1109 .wsec-btn-primary:hover {
1110 border-color: rgba(140,109,255,0.65);
1111 background: linear-gradient(135deg, rgba(140,109,255,0.28), rgba(54,197,214,0.20));
1112 }
1113 .wsec-btn-danger {
1114 border-color: rgba(248,113,113,0.32);
1115 color: #fecaca;
1116 }
1117 .wsec-btn-danger:hover {
1118 border-color: rgba(248,113,113,0.55);
1119 background: rgba(248,113,113,0.10);
1120 color: #fee2e2;
1121 }
f5b9ef5Claude1122 .wsec-btn-test {
1123 flex-shrink: 0;
1124 border-color: rgba(140,109,255,0.30);
1125 color: #c5b3ff;
1126 }
1127 .wsec-btn-test:hover {
1128 border-color: rgba(140,109,255,0.55);
1129 background: rgba(140,109,255,0.10);
1130 }
1131 .wsec-btn-test.is-ok {
1132 border-color: rgba(52,211,153,0.45);
1133 background: rgba(52,211,153,0.08);
1134 color: #6ee7b7;
1135 }
1136 .wsec-btn-test.is-error {
1137 border-color: rgba(248,113,113,0.45);
1138 background: rgba(248,113,113,0.08);
1139 color: #fca5a5;
1140 }
1141 .wsec-name-hint-ok { color: #6ee7b7; font-weight: 500; }
1142 .wsec-name-hint-err { color: #fca5a5; font-weight: 500; }
54eab24Claude1143
1144 @media (max-width: 640px) {
1145 .wsec-row { flex-direction: column; align-items: flex-start; }
1146 .wsec-row-actions { width: 100%; }
1147 .wsec-row-actions form { width: 100%; }
1148 .wsec-row-actions .wsec-btn { width: 100%; justify-content: center; }
1149 }
1150`;
1151
f5b9ef5Claude1152/* ─────────────────────────────────────────────────────────────────────────
1153 * Client-side script — validates the secret name field in real-time and
1154 * drives the Test button feedback. No external dependencies.
1155 * ───────────────────────────────────────────────────────────────────── */
abfa9adClaude1156export default workflowSecretsRoutes;