molecule-core/canvas/src/components/MissingKeysModal.tsx
Molecule AI Core-UIUX b6d596e596 fix(canvas): add focus-visible rings across 6 more files
WCAG 2.4.7 — Focus Visible.

PricingTable: CTA button now has focus-visible ring.
MissingKeysModal: Save, Open Settings, Cancel Deploy, Deploy buttons.
FilesToolbar: New, Upload, Export, Clear, Refresh buttons.
FileTreeContextMenu: menuitem buttons — replaced focus:bg with
  focus-visible:ring-2 (proper keyboard-only visible ring).
AgentCommsPanel: Restart and Open peer buttons.
settings-panel.css: secret-row cancel/save, add-key-form cancel/save
  all gain focus-visible ring.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-11 03:12:55 +00:00

762 lines
28 KiB
TypeScript

"use client";
import { useState, useEffect, useCallback, useRef, useMemo } from "react";
import { createPortal } from "react-dom";
import { api } from "@/lib/api";
import {
getKeyLabel,
type ModelSpec,
type ProviderChoice,
} from "@/lib/deploy-preflight";
import {
ProviderModelSelector,
buildProviderCatalog,
findProviderForModel,
type SelectorValue,
} from "./ProviderModelSelector";
interface Props {
open: boolean;
/** Flat list of every candidate env var. Used as the fallback input
* set when `providers` is empty (or length 1). */
missingKeys: string[];
/** Grouped provider options derived from the template's models[] /
* required_env. When length ≥ 2 the modal shows a radio picker. */
providers?: ProviderChoice[];
/** Runtime slug — used only for the "The <runtime> runtime …"
* headline; behavior is driven by providers/missingKeys. */
runtime: string;
/** Called when all required keys for the chosen provider are saved.
* Receives the model slug if the modal collected one (template-deploy
* flow); legacy callers ignore it. */
onKeysAdded: (model?: string) => void;
/** Called when the user cancels the deploy. */
onCancel: () => void;
/** Optional — open the Settings Panel (Config tab → Secrets). */
onOpenSettings?: () => void;
/** If provided, secrets save at workspace scope instead of global. */
workspaceId?: string;
/** Set of env var names already configured in the relevant scope
* (global or workspace). When provided, entries whose key is already
* in this set start as `saved: true` so the user can confirm without
* re-entering. Used by the template-deploy "always ask" flow so a
* user can pick a different provider even when global env covers
* the default one. */
configuredKeys?: Set<string>;
/** Model slug suggestions (datalist) — populated from the template's
* models[]. When non-empty the picker renders a model input above
* the API-key fields. The picker passes the entered slug back via
* onKeysAdded. */
modelSuggestions?: string[];
/** Full model specs from the template (with required_env per model).
* When provided, the picker auto-snaps the provider radio to the
* matching provider as the user changes the model — fixes the
* "type MiniMax model, see ANTHROPIC_API_KEY field" cascade bug
* (sibling of the ConfigTab cascade fix in #2516). Optional so
* callers without model→provider mapping data can still use the
* picker as-is. */
models?: ModelSpec[];
/** Pre-fill the model input. */
initialModel?: string;
/** Override the modal's title + description copy. The default
* "Missing API Keys" title misreads when the modal is opened to
* pick provider/model with keys already configured. */
title?: string;
description?: string;
}
interface KeyEntry {
key: string;
value: string;
saved: boolean;
saving: boolean;
error: string | null;
}
/**
* MissingKeysModal
* ----------------
* Dispatches between two modes based on what the template declares:
*
* 1. PROVIDER PICKER — when the preflight returned ≥2 `providers` (e.g.
* a Hermes template whose models[].required_env enumerate OpenRouter,
* Anthropic, Nous-native, etc.). Radio list of options, saving the
* chosen option's env vars satisfies the deploy.
*
* 2. ALL-KEYS — every entry in `missingKeys` rendered as its own input,
* all must save before Deploy. Used when the template has a single
* provider option or no declared alternatives.
*
* The modal never hardcodes per-runtime provider lists; the upstream
* preflight derives that from the template config.yaml.
*/
export function MissingKeysModal({
open,
missingKeys,
providers,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
description,
}: Props) {
const pickerProviders = providers ?? [];
const pickerMode = pickerProviders.length > 1;
if (pickerMode) {
return (
<ProviderPickerModal
open={open}
providers={pickerProviders}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
configuredKeys={configuredKeys}
modelSuggestions={modelSuggestions}
models={models}
initialModel={initialModel}
title={title}
description={description}
/>
);
}
// Prefer the (single) provider's envVars over the raw missingKeys when
// we have one — the provider list is already de-duped and ordered.
const keys =
pickerProviders.length === 1 ? pickerProviders[0].envVars : missingKeys;
return (
<AllKeysModal
open={open}
missingKeys={keys}
runtime={runtime}
onKeysAdded={onKeysAdded}
onCancel={onCancel}
onOpenSettings={onOpenSettings}
workspaceId={workspaceId}
/>
);
}
// -----------------------------------------------------------------------------
// Provider-picker mode — choose one option, save its env var(s), deploy.
// -----------------------------------------------------------------------------
/** Provider id derived from a model spec — sorted+joined required_env,
* matching the formula in providersFromTemplate(). When the model has
* no required_env (local/self-hosted endpoints) returns null, since
* there's no provider option the radio could snap to. Exported for
* the cascade-snap test. */
export function providerIdForModel(
modelId: string,
models: ModelSpec[] | undefined,
): string | null {
const trimmed = modelId.trim();
if (!trimmed || !models) return null;
const m = models.find((x) => x.id === trimmed);
if (!m?.required_env || m.required_env.length === 0) return null;
return [...m.required_env].sort().join("|");
}
function ProviderPickerModal({
open,
providers,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
configuredKeys,
modelSuggestions,
models,
initialModel,
title,
description,
}: {
open: boolean;
providers: ProviderChoice[];
runtime: string;
onKeysAdded: (model?: string) => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
configuredKeys?: Set<string>;
modelSuggestions?: string[];
models?: ModelSpec[];
initialModel?: string;
title?: string;
description?: string;
}) {
// Single model source: `models` from caller when present, else
// synthesize a stub list from the legacy `providers` shape so older
// callers (pre-PR-2534) still drive the picker. ProviderModelSelector
// and findProviderForModel BOTH consume this list — passing the same
// shape to both keeps ids identical, so back-derivation matches the
// dropdown's option values.
const selectorModels = useMemo(() => {
if (models && models.length > 0) return models;
return providers.map((p) => ({
id: p.id,
name: p.label,
required_env: p.envVars,
}));
}, [models, providers]);
const catalog = useMemo(() => buildProviderCatalog(selectorModels), [selectorModels]);
// Initial selector value: prefer back-derivation from initialModel
// (template-deploy passes the template default), then the first
// provider already satisfied by configuredKeys, then catalog[0].
const initial = useMemo<SelectorValue>(() => {
if (initialModel) {
const matched = findProviderForModel(catalog, initialModel);
if (matched) {
return {
providerId: matched.id,
model: initialModel,
envVars: matched.envVars,
};
}
}
if (configuredKeys) {
const satisfied = catalog.find((p) =>
p.envVars.every((k) => configuredKeys.has(k)),
);
if (satisfied) {
return {
providerId: satisfied.id,
model: satisfied.wildcard ? "" : satisfied.models[0]?.id ?? "",
envVars: satisfied.envVars,
};
}
}
const first = catalog[0];
if (!first) return { providerId: "", model: "", envVars: [] };
return {
providerId: first.id,
model: first.wildcard ? "" : first.models[0]?.id ?? "",
envVars: first.envVars,
};
}, [catalog, initialModel, configuredKeys]);
const [selectorValue, setSelectorValue] = useState<SelectorValue>(initial);
const [entries, setEntries] = useState<KeyEntry[]>([]);
const firstInputRef = useRef<HTMLInputElement>(null);
// Legacy compat: map the selector value back into the old `selected`/
// `model` shape for the rest of the modal body (footer copy, etc.).
const selected = useMemo(
() =>
providers.find((p) => p.id === selectorValue.providerId) ??
providers[0],
[providers, selectorValue.providerId],
);
const model = selectorValue.model;
const showModelInput = catalog.length > 0;
useEffect(() => {
if (!open) return;
setSelectorValue(initial);
}, [open, initial]);
useEffect(() => {
if (!open) return;
setEntries(
selectorValue.envVars.map((key) => ({
key,
value: "",
// Pre-mark as saved when the key is already in the configured
// set (global or workspace scope). Lets the user click Deploy
// without re-entering a key the platform already holds.
saved: configuredKeys?.has(key) ?? false,
saving: false,
error: null,
})),
);
}, [open, selectorValue.envVars, configuredKeys]);
useEffect(() => {
if (!open) return;
const raf = requestAnimationFrame(() => firstInputRef.current?.focus());
return () => cancelAnimationFrame(raf);
}, [open, selectorValue.providerId]);
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((e, i) => (i === index ? { ...e, ...updates } : e)),
);
},
[],
);
const handleSaveKey = useCallback(
async (index: number) => {
const entry = entries[index];
if (!entry.value.trim()) return;
updateEntry(index, { saving: true, error: null });
try {
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],
);
if (!open) return null;
// Portal to document.body for the same reason as
// OrgImportPreflightModal — several callers (TemplatePalette,
// EmptyState) render the modal inside their own fixed+filtered
// containers, which re-anchor the "fixed" positioning to the
// wrapper's bounds instead of the viewport.
if (typeof document === "undefined") return null;
const allSaved = entries.length > 0 && 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 createPortal(
// z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50).
// Both can be on screen at once during an org import: the org-
// preflight is open while the user clicks a per-workspace deploy
// that triggers MissingKeys. Without the explicit z-order the
// backdrop click might dismiss the wrong modal depending on
// React's commit ordering.
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
aria-hidden="true"
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onCancel}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[480px] w-full mx-4 max-h-[80vh] overflow-auto"
>
<div className="px-5 py-4 border-b border-line">
<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"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<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 id="missing-keys-title" className="text-sm font-semibold text-ink">
{title ?? "Missing API Keys"}
</h3>
</div>
<p className="text-[12px] text-ink-mid leading-relaxed">
{description ?? (
<>
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime supports multiple providers. Pick one and paste its API key.
</>
)}
</p>
</div>
<div className="px-5 py-4 space-y-3">
{/* Shared provider→model selector. Source of truth for provider
taxonomy + model filtering. Same component is used in
ConfigTab so behavior + vendor split is identical across
all 3 deploy surfaces (modal here, settings tab, template
palette flow). */}
<ProviderModelSelector
models={selectorModels}
value={selectorValue}
onChange={setSelectorValue}
variant="stack"
idPrefix="provider-picker"
/>
<div className="space-y-2">
{entries.map((entry, index) => (
<div
key={entry.key}
className="bg-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
>
<div className="flex items-center justify-between mb-1.5">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good 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" aria-hidden="true">
<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"
ref={index === 0 ? firstInputRef : undefined}
onKeyDown={(e) => {
if (e.key === "Enter" && entry.value.trim()) {
handleSaveKey(index);
}
}}
className="flex-1 bg-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent 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-bad">{entry.error}</div>
)}
</div>
))}
</div>
</div>
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent 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-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors"
>
Cancel Deploy
</button>
<button
onClick={() => onKeysAdded(showModelInput ? model.trim() : undefined)}
disabled={
!allSaved ||
anySaving ||
!selectorValue.providerId ||
(showModelInput && model.trim() === "")
}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40"
>
{allSaved ? "Deploy" : entries.length > 1 ? "Add Keys" : "Add Key"}
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}
// -----------------------------------------------------------------------------
// All-keys mode — every missingKey rendered as its own input, all required.
// -----------------------------------------------------------------------------
function AllKeysModal({
open,
missingKeys,
runtime,
onKeysAdded,
onCancel,
onOpenSettings,
workspaceId,
}: {
open: boolean;
missingKeys: string[];
runtime: string;
onKeysAdded: () => void;
onCancel: () => void;
onOpenSettings?: () => void;
workspaceId?: string;
}) {
const [entries, setEntries] = useState<KeyEntry[]>([]);
const [globalError, setGlobalError] = useState<string | null>(null);
useEffect(() => {
if (!open) return;
setEntries(
missingKeys.map((key) => ({
key,
value: "",
saved: false,
saving: false,
error: null,
})),
);
setGlobalError(null);
}, [open, missingKeys]);
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 {
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]);
// Focus trap: auto-focus first input when modal opens
useEffect(() => {
if (!open) return;
const timer = requestAnimationFrame(() => {
document.getElementById("missing-keys-title")?.focus();
});
return () => cancelAnimationFrame(timer);
}, [open]);
if (!open) return null;
if (typeof document === "undefined") return null;
const allSaved = entries.length > 0 && 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 createPortal(
// z-[60] so this stacks ABOVE OrgImportPreflightModal (z-50).
// Both can be on screen at once during an org import: the org-
// preflight is open while the user clicks a per-workspace deploy
// that triggers MissingKeys. Without the explicit z-order the
// backdrop click might dismiss the wrong modal depending on
// React's commit ordering.
<div className="fixed inset-0 z-[60] flex items-center justify-center">
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
aria-hidden="true"
onClick={onCancel}
/>
<div
role="dialog"
aria-modal="true"
aria-labelledby="missing-keys-title"
className="relative bg-surface-sunken border border-line rounded-xl shadow-2xl shadow-black/50 max-w-[440px] w-full mx-4 max-h-[80vh] overflow-auto"
>
<div className="px-5 py-4 border-b border-line">
<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"
aria-hidden="true"
>
<svg width="12" height="12" viewBox="0 0 12 12" fill="none" aria-hidden="true">
<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 id="missing-keys-title" className="text-sm font-semibold text-ink">
Missing API Keys
</h3>
</div>
<p className="text-[12px] text-ink-mid leading-relaxed">
The <span className="text-warm font-medium">{runtimeLabel}</span>{" "}
runtime requires the following keys to be configured before deploying.
</p>
</div>
<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-surface-card/50 rounded-lg px-3 py-2.5 border border-line/50"
>
<div className="flex items-center justify-between mb-1">
<div>
<div className="text-[11px] text-ink-mid font-medium">
{getKeyLabel(entry.key)}
</div>
<div className="text-[9px] font-mono text-ink-mid">{entry.key}</div>
</div>
{entry.saved && (
<span className="text-[9px] text-good 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-surface-sunken border border-line rounded px-2 py-1.5 text-[11px] text-ink font-mono focus:outline-none focus:border-accent focus:ring-1 focus:ring-accent/20 transition-colors"
/>
<button
type="button"
onClick={() => handleSaveKey(index)}
disabled={!entry.value.trim() || entry.saving}
className="px-3 py-1.5 bg-accent-strong hover:bg-accent text-[11px] rounded text-white disabled:opacity-30 transition-colors shrink-0 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{entry.saving ? "..." : "Save"}
</button>
</div>
)}
{entry.error && <div className="mt-1.5 text-[10px] text-bad">{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-bad">
{globalError}
</div>
)}
</div>
<div className="px-5 py-3 border-t border-line bg-surface/50 flex items-center justify-between gap-2">
<div>
{onOpenSettings && (
<button
type="button"
onClick={onOpenSettings}
className="text-[11px] text-accent hover:text-accent transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Open Settings Panel
</button>
)}
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={onCancel}
className="px-3.5 py-1.5 text-[12px] text-ink-mid hover:text-ink bg-surface-card hover:bg-surface-card border border-line rounded-lg transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
Cancel Deploy
</button>
<button
type="button"
onClick={handleAddKeysAndDeploy}
disabled={!allSaved || anySaving}
className="px-3.5 py-1.5 text-[12px] bg-accent-strong hover:bg-accent text-white rounded-lg transition-colors disabled:opacity-40 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-1"
>
{anySaving ? "Saving..." : allSaved ? "Deploy" : "Add Keys"}
</button>
</div>
</div>
</div>
</div>,
document.body,
);
}