diff --git a/canvas/src/components/MissingKeysModal.tsx b/canvas/src/components/MissingKeysModal.tsx index c98b27f01..bb90328f3 100644 --- a/canvas/src/components/MissingKeysModal.tsx +++ b/canvas/src/components/MissingKeysModal.tsx @@ -23,6 +23,8 @@ interface Props { /** Grouped provider options derived from the template's models[] / * required_env. When length ≥ 2 the modal shows a radio picker. */ providers?: ProviderChoice[]; + /** Optional keys to offer in the deploy modal without blocking Deploy. */ + optionalKeys?: string[]; /** Runtime slug — used only for the "The runtime …" * headline; behavior is driven by providers/missingKeys. */ runtime: string; @@ -94,13 +96,13 @@ export function MissingKeysModal({ open, missingKeys, providers, + optionalKeys, runtime, onKeysAdded, onCancel, onOpenSettings, workspaceId, configuredKeys, - modelSuggestions, models, initialModel, title, @@ -114,13 +116,13 @@ export function MissingKeysModal({ ); } @@ -170,13 +176,13 @@ export function providerIdForModel( function ProviderPickerModal({ open, providers, + optionalKeys, runtime, onKeysAdded, onCancel, onOpenSettings, workspaceId, configuredKeys, - modelSuggestions, models, initialModel, title, @@ -184,13 +190,13 @@ function ProviderPickerModal({ }: { open: boolean; providers: ProviderChoice[]; + optionalKeys: string[]; runtime: string; onKeysAdded: (model?: string) => void; onCancel: () => void; onOpenSettings?: () => void; workspaceId?: string; configuredKeys?: Set; - modelSuggestions?: string[]; models?: ModelSpec[]; initialModel?: string; title?: string; @@ -250,16 +256,9 @@ function ProviderPickerModal({ const [selectorValue, setSelectorValue] = useState(initial); const [entries, setEntries] = useState([]); + const [optionalEntries, setOptionalEntries] = useState([]); const firstInputRef = useRef(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; @@ -282,7 +281,18 @@ function ProviderPickerModal({ error: null, })), ); - }, [open, selectorValue.envVars, configuredKeys]); + setOptionalEntries( + optionalKeys + .filter((key) => !selectorValue.envVars.includes(key)) + .map((key) => ({ + key, + value: "", + saved: configuredKeys?.has(key) ?? false, + saving: false, + error: null, + })), + ); + }, [open, selectorValue.envVars, configuredKeys, optionalKeys]); useEffect(() => { if (!open) return; @@ -336,6 +346,43 @@ function ProviderPickerModal({ [entries, updateEntry, workspaceId], ); + const updateOptionalEntry = useCallback( + (index: number, updates: Partial) => { + setOptionalEntries((prev) => + prev.map((e, i) => (i === index ? { ...e, ...updates } : e)), + ); + }, + [], + ); + + const handleSaveOptionalKey = useCallback( + async (index: number) => { + const entry = optionalEntries[index]; + if (!entry.value.trim()) return; + updateOptionalEntry(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(), + }); + } + updateOptionalEntry(index, { saved: true, saving: false }); + } catch (e) { + updateOptionalEntry(index, { + saving: false, + error: e instanceof Error ? e.message : "Failed to save", + }); + } + }, + [optionalEntries, updateOptionalEntry, workspaceId], + ); + if (!open) return null; // Portal to document.body for the same reason as // OrgImportPreflightModal — several callers (TemplatePalette, @@ -465,6 +512,62 @@ function ProviderPickerModal({ ))} + + {optionalEntries.length > 0 && ( +
+
+ Optional +
+ {optionalEntries.map((entry, index) => ( +
+
+
+
+ {getKeyLabel(entry.key)} +
+
{entry.key}
+
+ {entry.saved && ( + + Saved + + )} +
+ {!entry.saved && ( +
+ updateOptionalEntry(index, { value: e.target.value.trimStart() })} + placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"} + type="password" + aria-label={`Optional value for ${entry.key}`} + onKeyDown={(e) => { + if (e.key === "Enter" && entry.value.trim()) { + handleSaveOptionalKey(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" + /> + +
+ )} + {entry.error && ( +
{entry.error}
+ )} +
+ ))} +
+ )}
@@ -512,21 +615,30 @@ function ProviderPickerModal({ function AllKeysModal({ open, missingKeys, + optionalKeys, runtime, onKeysAdded, onCancel, onOpenSettings, workspaceId, + configuredKeys, + title, + description, }: { open: boolean; missingKeys: string[]; + optionalKeys: string[]; runtime: string; onKeysAdded: () => void; onCancel: () => void; onOpenSettings?: () => void; workspaceId?: string; + configuredKeys?: Set; + title?: string; + description?: string; }) { const [entries, setEntries] = useState([]); + const [optionalEntries, setOptionalEntries] = useState([]); const [globalError, setGlobalError] = useState(null); useEffect(() => { @@ -535,13 +647,24 @@ function AllKeysModal({ missingKeys.map((key) => ({ key, value: "", - saved: false, + saved: configuredKeys?.has(key) ?? false, saving: false, error: null, })), ); + setOptionalEntries( + optionalKeys + .filter((key) => !missingKeys.includes(key)) + .map((key) => ({ + key, + value: "", + saved: configuredKeys?.has(key) ?? false, + saving: false, + error: null, + })), + ); setGlobalError(null); - }, [open, missingKeys]); + }, [open, missingKeys, optionalKeys, configuredKeys]); useEffect(() => { if (!open) return; @@ -591,6 +714,45 @@ function AllKeysModal({ [entries, updateEntry, workspaceId], ); + const updateOptionalEntry = useCallback( + (index: number, updates: Partial) => { + setOptionalEntries((prev) => + prev.map((entry, i) => (i === index ? { ...entry, ...updates } : entry)), + ); + }, + [], + ); + + const handleSaveOptionalKey = useCallback( + async (index: number) => { + const entry = optionalEntries[index]; + if (!entry.value.trim()) return; + + updateOptionalEntry(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(), + }); + } + updateOptionalEntry(index, { saved: true, saving: false }); + } catch (e) { + updateOptionalEntry(index, { + saving: false, + error: e instanceof Error ? e.message : "Failed to save", + }); + } + }, + [optionalEntries, updateOptionalEntry, workspaceId], + ); + const handleAddKeysAndDeploy = useCallback(() => { const anySaving = entries.some((e) => e.saving); if (anySaving) { @@ -656,12 +818,16 @@ function AllKeysModal({

- Missing API Keys + {title ?? "Missing API Keys"}

- The {runtimeLabel}{" "} - runtime requires the following keys to be configured before deploying. + {description ?? ( + <> + The {runtimeLabel}{" "} + runtime requires the following keys to be configured before deploying. + + )}

@@ -719,6 +885,62 @@ function AllKeysModal({ ))} + {optionalEntries.length > 0 && ( +
+
+ Optional +
+ {optionalEntries.map((entry, index) => ( +
+
+
+
+ {getKeyLabel(entry.key)} +
+
{entry.key}
+
+ {entry.saved && ( + + Saved + + )} +
+ + {!entry.saved && ( +
+ updateOptionalEntry(index, { value: e.target.value.trimStart() })} + placeholder={entry.key.includes("API_KEY") ? "sk-..." : "Enter value"} + type="password" + aria-label={`Optional value for ${entry.key}`} + onKeyDown={(e) => { + if (e.key === "Enter" && entry.value.trim()) { + handleSaveOptionalKey(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" + /> + +
+ )} + + {entry.error &&
{entry.error}
} +
+ ))} +
+ )} + {globalError && (
{globalError} diff --git a/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx b/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx index dad323681..eb1ef3e3e 100644 --- a/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx +++ b/canvas/src/components/__tests__/MissingKeysModal.component.test.tsx @@ -402,6 +402,31 @@ describe("MissingKeysModal — add keys and deploy", () => { expect(onKeysAdded).toHaveBeenCalled(); }); + it("shows optional keys without blocking deploy", () => { + const onKeysAdded = vi.fn(); + render( + + ); + + expect(screen.getByText("Optional")).toBeTruthy(); + expect(screen.getAllByText("GOOGLE_GSC_SITE").length).toBeGreaterThan(0); + const deployBtn = Array.from(document.querySelectorAll("button")).find( + (b) => b.textContent?.trim() === "Deploy", + ); + expect(deployBtn).toBeTruthy(); + expect(deployBtn!.disabled).toBe(false); + act(() => { fireEvent.click(deployBtn!); }); + expect(onKeysAdded).toHaveBeenCalled(); + }); + it("shows global error when not all keys saved", async () => { const onKeysAdded = vi.fn(); render( @@ -529,4 +554,4 @@ describe("MissingKeysModal — cancel and settings", () => { ); expect(screen.queryByRole("button", { name: /open settings/i })).toBeNull(); }); -}); \ No newline at end of file +}); diff --git a/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx b/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx index 740d1d0ea..6f9ff25bc 100644 --- a/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx +++ b/canvas/src/hooks/__tests__/useTemplateDeploy.test.tsx @@ -63,6 +63,7 @@ vi.mock("@/components/MissingKeysModal", () => ({ onKeysAdded: (model?: string) => void; onCancel: () => void; configuredKeys?: Set; + optionalKeys?: string[]; modelSuggestions?: string[]; initialModel?: string; title?: string; @@ -77,6 +78,9 @@ vi.mock("@/components/MissingKeysModal", () => ({ {props.initialModel ?? ""} {props.title ?? ""} + + {(props.optionalKeys ?? []).join(",")} +