forked from molecule-ai/molecule-core
zinc-600 on zinc-900/950 background ≈ 2.6:1 contrast (WCAG AA requires 4.5:1 for text under 18pt). Found 15 instances across 9 components where small-text data labels used this low-contrast pairing. Files and what they label: EmptyState.tsx:132 — skill count + model on template cards (new-user visible) SidePanel.tsx:230 — workspace ID in panel footer (copyable, functional) ActivityTab.tsx:210 — entry timestamp (8px) ActivityTab.tsx:214 — expand chevron affordance (9px) ActivityTab.tsx:236 — "→" direction arrow between agents (9px) ActivityTab.tsx:278 — entry ID (8px, font-mono) ScheduleTab.tsx:284 — empty-state description text (9px) ScheduleTab.tsx:320 — schedule prompt preview (9px, truncate) ScheduleTab.tsx:323 — last/next/run-count metadata row (8px) SkillsTab.tsx:380 — "Examples" section header (9px uppercase) TracesTab.tsx:132 — trace ID (8px, font-mono) AgentCommsPanel.tsx:166 — message timestamp (9px) secrets-section.tsx:59 — secret key name (9px, font-mono) secrets-section.tsx:308 — encryption notice (9px) MissingKeysModal.tsx:175 — missing key identifier (9px, font-mono) Fix: zinc-600 → zinc-500 across all 15 instances. Purely cosmetic — no logic, no layout, no interactive behaviour changed. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
259 lines
8.9 KiB
TypeScript
259 lines
8.9 KiB
TypeScript
"use client";
|
|
|
|
import { useState, useEffect, useCallback } from "react";
|
|
import { api } from "@/lib/api";
|
|
import { getKeyLabel } from "@/lib/deploy-preflight";
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
missingKeys: string[];
|
|
runtime: string;
|
|
/** Called when user adds all keys and wants to proceed with deploy. */
|
|
onKeysAdded: () => void;
|
|
/** Called when user cancels the deploy. */
|
|
onCancel: () => void;
|
|
/** Called when user wants to open the Settings Panel (Config tab → Secrets). */
|
|
onOpenSettings?: () => void;
|
|
/** Optional workspace ID — if provided, secrets are saved at workspace scope. */
|
|
workspaceId?: string;
|
|
}
|
|
|
|
interface KeyEntry {
|
|
key: string;
|
|
label: string;
|
|
value: string;
|
|
saved: boolean;
|
|
saving: boolean;
|
|
error: string | null;
|
|
}
|
|
|
|
export function MissingKeysModal({
|
|
open,
|
|
missingKeys,
|
|
runtime,
|
|
onKeysAdded,
|
|
onCancel,
|
|
onOpenSettings,
|
|
workspaceId,
|
|
}: Props) {
|
|
const [entries, setEntries] = useState<KeyEntry[]>([]);
|
|
const [globalError, setGlobalError] = useState<string | null>(null);
|
|
|
|
// Initialize entries when modal opens or missingKeys change
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
setEntries(
|
|
missingKeys.map((key) => ({
|
|
key,
|
|
label: getKeyLabel(key),
|
|
value: "",
|
|
saved: false,
|
|
saving: false,
|
|
error: null,
|
|
})),
|
|
);
|
|
setGlobalError(null);
|
|
}, [open, missingKeys]);
|
|
|
|
// Keyboard handler
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
const handler = (e: KeyboardEvent) => {
|
|
if (e.key === "Escape") onCancel();
|
|
};
|
|
window.addEventListener("keydown", handler);
|
|
return () => window.removeEventListener("keydown", handler);
|
|
}, [open, onCancel]);
|
|
|
|
const updateEntry = useCallback(
|
|
(index: number, updates: Partial<KeyEntry>) => {
|
|
setEntries((prev) =>
|
|
prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)),
|
|
);
|
|
},
|
|
[],
|
|
);
|
|
|
|
const handleSaveKey = useCallback(
|
|
async (index: number) => {
|
|
const entry = entries[index];
|
|
if (!entry.value.trim()) return;
|
|
|
|
updateEntry(index, { saving: true, error: null });
|
|
|
|
try {
|
|
// Save to global scope by default (available to all workspaces)
|
|
if (workspaceId) {
|
|
await api.put(`/workspaces/${workspaceId}/secrets`, {
|
|
key: entry.key,
|
|
value: entry.value.trim(),
|
|
});
|
|
} else {
|
|
await api.put("/settings/secrets", {
|
|
key: entry.key,
|
|
value: entry.value.trim(),
|
|
});
|
|
}
|
|
updateEntry(index, { saved: true, saving: false });
|
|
} catch (e) {
|
|
updateEntry(index, {
|
|
saving: false,
|
|
error: e instanceof Error ? e.message : "Failed to save",
|
|
});
|
|
}
|
|
},
|
|
[entries, updateEntry, workspaceId],
|
|
);
|
|
|
|
const handleAddKeysAndDeploy = useCallback(() => {
|
|
const anySaving = entries.some((e) => e.saving);
|
|
if (anySaving) {
|
|
setGlobalError("Please wait for all keys to finish saving.");
|
|
return;
|
|
}
|
|
const allSaved = entries.every((e) => e.saved);
|
|
if (!allSaved) {
|
|
setGlobalError("Please save all required keys before deploying.");
|
|
return;
|
|
}
|
|
onKeysAdded();
|
|
}, [entries, onKeysAdded]);
|
|
|
|
if (!open) return null;
|
|
|
|
const allSaved = entries.every((e) => e.saved);
|
|
const anySaving = entries.some((e) => e.saving);
|
|
const runtimeLabel = runtime.replace(/[-_]/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
|
|
|
|
return (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
{/* Backdrop */}
|
|
<div
|
|
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
|
|
onClick={onCancel}
|
|
/>
|
|
|
|
{/* Dialog */}
|
|
<div className="relative bg-zinc-900 border border-zinc-700 rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 overflow-hidden">
|
|
{/* Header */}
|
|
<div className="px-5 py-4 border-b border-zinc-800">
|
|
<div className="flex items-center gap-2 mb-1">
|
|
<div className="w-5 h-5 rounded-md bg-amber-600/20 border border-amber-500/30 flex items-center justify-center">
|
|
<svg width="12" height="12" viewBox="0 0 12 12" fill="none">
|
|
<path
|
|
d="M6 1L11 10H1L6 1Z"
|
|
stroke="#fbbf24"
|
|
strokeWidth="1.2"
|
|
strokeLinejoin="round"
|
|
/>
|
|
<path d="M6 5V7" stroke="#fbbf24" strokeWidth="1.2" strokeLinecap="round" />
|
|
<circle cx="6" cy="8.5" r="0.5" fill="#fbbf24" />
|
|
</svg>
|
|
</div>
|
|
<h3 className="text-sm font-semibold text-zinc-100">
|
|
Missing API Keys
|
|
</h3>
|
|
</div>
|
|
<p className="text-[12px] text-zinc-400 leading-relaxed">
|
|
The <span className="text-amber-300 font-medium">{runtimeLabel}</span> runtime
|
|
requires the following keys to be configured before deploying.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Body — key list */}
|
|
<div className="px-5 py-4 space-y-3 max-h-[50vh] overflow-y-auto">
|
|
{entries.map((entry, index) => (
|
|
<div
|
|
key={entry.key}
|
|
className="bg-zinc-800/50 rounded-lg px-3 py-2.5 border border-zinc-700/50"
|
|
>
|
|
<div className="flex items-center justify-between mb-1">
|
|
<div>
|
|
<div className="text-[11px] text-zinc-300 font-medium">
|
|
{entry.label}
|
|
</div>
|
|
<div className="text-[9px] font-mono text-zinc-500">
|
|
{entry.key}
|
|
</div>
|
|
</div>
|
|
{entry.saved && (
|
|
<span className="text-[9px] text-emerald-400 bg-emerald-900/30 px-1.5 py-0.5 rounded flex items-center gap-1">
|
|
<svg width="8" height="8" viewBox="0 0 8 8" fill="none">
|
|
<path d="M1.5 4L3.5 6L6.5 2" stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
|
|
</svg>
|
|
Saved
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{!entry.saved && (
|
|
<div className="flex gap-2 mt-2">
|
|
<input
|
|
value={entry.value}
|
|
onChange={(e) => updateEntry(index, { value: e.target.value.trimStart() })}
|
|
placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"}
|
|
type="password"
|
|
autoFocus={index === 0}
|
|
onKeyDown={(e) => {
|
|
if (e.key === "Enter" && entry.value.trim()) {
|
|
handleSaveKey(index);
|
|
}
|
|
}}
|
|
className="flex-1 bg-zinc-900 border border-zinc-600 rounded px-2 py-1.5 text-[11px] text-zinc-100 font-mono focus:outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-colors"
|
|
/>
|
|
<button
|
|
onClick={() => handleSaveKey(index)}
|
|
disabled={!entry.value.trim() || entry.saving}
|
|
className="px-3 py-1.5 bg-blue-600 hover:bg-blue-500 text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0"
|
|
>
|
|
{entry.saving ? "..." : "Save"}
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{entry.error && (
|
|
<div className="mt-1.5 text-[10px] text-red-400">{entry.error}</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
|
|
{globalError && (
|
|
<div className="px-3 py-2 bg-red-950/40 border border-red-800/50 rounded-lg text-[11px] text-red-400">
|
|
{globalError}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Footer */}
|
|
<div className="px-5 py-3 border-t border-zinc-800 bg-zinc-950/50 flex items-center justify-between gap-2">
|
|
<div>
|
|
{onOpenSettings && (
|
|
<button
|
|
onClick={onOpenSettings}
|
|
className="text-[11px] text-blue-400 hover:text-blue-300 transition-colors"
|
|
>
|
|
Open Settings Panel
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
onClick={onCancel}
|
|
className="px-3.5 py-1.5 text-[12px] text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 border border-zinc-700 rounded-lg transition-colors"
|
|
>
|
|
Cancel Deploy
|
|
</button>
|
|
<button
|
|
onClick={handleAddKeysAndDeploy}
|
|
disabled={!allSaved || anySaving}
|
|
className="px-3.5 py-1.5 text-[12px] bg-blue-600 hover:bg-blue-500 text-white rounded-lg transition-colors disabled:opacity-40"
|
|
>
|
|
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|