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>
762 lines
28 KiB
TypeScript
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,
|
|
);
|
|
}
|