diff --git a/frontend/src/api/services.ts b/frontend/src/api/services.ts index edcc0fa..0a19327 100644 --- a/frontend/src/api/services.ts +++ b/frontend/src/api/services.ts @@ -2,6 +2,7 @@ import { del, get, post, put } from "./shared"; import type { ServiceInstance, ServiceInstanceInput, + ServiceTestResult, ServiceTypeInfo, } from "../types"; @@ -36,3 +37,9 @@ export async function deleteServiceInstance( ): Promise<{ status: string }> { return del<{ status: string }>(`/api/services/instances/${serviceId}`); } + +export async function testServiceInstance( + input: ServiceInstanceInput, +): Promise { + return post("/api/services/test", input); +} diff --git a/frontend/src/components/ServiceTestPanel.tsx b/frontend/src/components/ServiceTestPanel.tsx new file mode 100644 index 0000000..167f2ce --- /dev/null +++ b/frontend/src/components/ServiceTestPanel.tsx @@ -0,0 +1,72 @@ +import { Alert, AlertDescription } from "@/components/ui/alert"; +import { Button } from "@/components/ui/button"; +import type { ServiceTestResult } from "../types"; + +interface Props { + /** Current test result (null = not tested yet). Parent clears this when the form input changes. */ + result: ServiceTestResult | null; + /** Whether the test mutation is in-flight. */ + isPending: boolean; + /** Whether the "Save anyway" checkbox is checked. */ + saveAnyway: boolean; + /** Fired when the user clicks "Test credentials". */ + onTest: () => void; + /** Fired when the "Save anyway" checkbox toggles. */ + onSaveAnywayChange: (checked: boolean) => void; + /** Disable the Test button (e.g. no draft yet). */ + disabled?: boolean; +} + +/** + * Shared "Test credentials" panel used by both the add-service dialog and the + * edit-service panel. Purely presentational — the parent owns the test result + * + saveAnyway state and the mutation hook. This avoids setState-in-effect + * issues with clearing the result on input change (the parent uses the + * React-recommended "store previous prop" pattern instead). + */ +export function ServiceTestPanel({ + result, + isPending, + saveAnyway, + onTest, + onSaveAnywayChange, + disabled, +}: Props) { + const testPassed = result?.ok === true; + + return ( +
+ + {result ? ( + + + {result.ok + ? `✓ Connected${result.evidence ? ` — ${result.evidence}` : ""}` + : `✗ ${result.detail}`} + + + ) : null} + + {!testPassed && !saveAnyway ? ( +

+ Test credentials or check "Save anyway" to enable the save button. +

+ ) : null} +
+ ); +} diff --git a/frontend/src/components/__tests__/ServiceTestPanel.test.tsx b/frontend/src/components/__tests__/ServiceTestPanel.test.tsx new file mode 100644 index 0000000..6ffd441 --- /dev/null +++ b/frontend/src/components/__tests__/ServiceTestPanel.test.tsx @@ -0,0 +1,115 @@ +import { describe, it, expect, vi } from "vitest"; +import { render, screen, fireEvent } from "@testing-library/react"; +import { ServiceTestPanel } from "../ServiceTestPanel"; +import type { ServiceTestResult } from "../../types"; + +function noop() {} + +describe("ServiceTestPanel", () => { + it("renders the Test credentials button", () => { + render( + , + ); + expect(screen.getByText("Test credentials")).toBeTruthy(); + }); + + it("shows Testing… and disables button while pending", () => { + render( + , + ); + expect(screen.getByText("Testing…")).toBeTruthy(); + expect(screen.getByText("Testing…")).toBeDisabled(); + }); + + it("renders green ✓ Connected pill with evidence on success", () => { + const result: ServiceTestResult = { + ok: true, + detail: "ok", + evidence: "v4.5.0", + }; + render( + , + ); + expect(screen.getByText(/✓ Connected — v4.5.0/)).toBeTruthy(); + }); + + it("renders red ✗ pill with detail on failure", () => { + const result: ServiceTestResult = { + ok: false, + detail: "Authentication failed", + evidence: null, + }; + render( + , + ); + expect(screen.getByText(/✗ Authentication failed/)).toBeTruthy(); + }); + + it("fires onTest when Test credentials is clicked", () => { + const onTest = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByText("Test credentials")); + expect(onTest).toHaveBeenCalledOnce(); + }); + + it("fires onSaveAnywayChange when checkbox is toggled", () => { + const onSaveAnywayChange = vi.fn(); + render( + , + ); + const checkbox = screen.getByRole("checkbox"); + fireEvent.click(checkbox); + expect(onSaveAnywayChange).toHaveBeenCalledWith(true); + }); + + it("renders the Save anyway checkbox", () => { + render( + , + ); + expect(screen.getByRole("checkbox")).toBeTruthy(); + }); +}); diff --git a/frontend/src/hooks/useServices.ts b/frontend/src/hooks/useServices.ts index a83848e..b4456a4 100644 --- a/frontend/src/hooks/useServices.ts +++ b/frontend/src/hooks/useServices.ts @@ -4,6 +4,7 @@ import { deleteServiceInstance, fetchServiceInstances, fetchServiceTypes, + testServiceInstance, updateServiceInstance, } from "../api/services"; import type { ServiceInstanceInput } from "../types"; @@ -45,3 +46,9 @@ export function useDeleteServiceInstance() { }, }); } + +export function useTestServiceInstance() { + return useMutation({ + mutationFn: (input: ServiceInstanceInput) => testServiceInstance(input), + }); +} diff --git a/frontend/src/pages/ServicesPage.tsx b/frontend/src/pages/ServicesPage.tsx index 7d4d64b..7f4faed 100644 --- a/frontend/src/pages/ServicesPage.tsx +++ b/frontend/src/pages/ServicesPage.tsx @@ -30,6 +30,7 @@ import { useDeleteServiceInstance, useSaveServiceInstance, useServiceInstances, + useTestServiceInstance, } from "../hooks/useServices"; import { useServiceTypes } from "../hooks/useServices"; import { @@ -41,12 +42,14 @@ import type { SecretFieldInfo, ServiceInstance, ServiceInstanceInput, + ServiceTestResult, ServiceTypeInfo, } from "../types"; import { SectionCard } from "../components/SectionCard"; import { ConfirmDialog } from "../components/ConfirmDialog"; import { DialogFooter } from "../components/DialogFooter"; import { getServiceBinding } from "../integrations/registry"; +import { ServiceTestPanel } from "../components/ServiceTestPanel"; import { serviceLinkTarget } from "../components/PinnedServiceLink"; import type { NamedDashboardInput } from "../api/dashboards"; @@ -174,12 +177,48 @@ function CreateServiceDialog({ }) { const { data: types = [] } = useServiceTypes(); const saveService = useSaveServiceInstance(); + const testService = useTestServiceInstance(); const [draft, setDraft] = useState(null); const [submitError, setSubmitError] = useState(null); + const [testResult, setTestResult] = useState(null); + const [saveAnyway, setSaveAnyway] = useState(false); + + // Reset test state when draft changes (React "store previous" pattern — + // avoids setState-in-effect). Any field edit creates a new draft object. + const [prevDraft, setPrevDraft] = useState(draft); + if (draft !== prevDraft) { + setPrevDraft(draft); + setTestResult(null); + setSaveAnyway(false); + } + + const testPassed = (testResult?.ok ?? false) || saveAnyway; function reset() { setDraft(null); setSubmitError(null); + setTestResult(null); + setSaveAnyway(false); + } + + async function handleTest() { + if (!draft) return; + try { + const res = await testService.mutateAsync({ + service_type: draft.serviceType, + name: draft.name.trim(), + config: draft.config, + secrets: draft.secrets, + enabled: draft.enabled, + }); + setTestResult(res); + } catch (err) { + setTestResult({ + ok: false, + detail: err instanceof Error ? err.message : String(err), + evidence: null, + }); + } } async function save() { @@ -196,7 +235,7 @@ function CreateServiceDialog({ try { await saveService.mutateAsync(input); reset(); - onClose(); + onClose(); } catch (err) { setSubmitError(err instanceof Error ? err.message : String(err)); } @@ -265,9 +304,16 @@ function CreateServiceDialog({ onCheckedChange={(checked) => setDraft({ ...draft, enabled: checked }) } - /> - - + /> + + + )} @@ -281,7 +327,7 @@ function CreateServiceDialog({ onCancel={reset} onConfirm={save} confirmLabel="Create service" - confirmDisabled={!draft.name.trim() || saveService.isPending} + confirmDisabled={!draft.name.trim() || saveService.isPending || !testPassed} /> ) : null} diff --git a/frontend/src/pages/Settings.tsx b/frontend/src/pages/Settings.tsx index 4eb6d78..ef86231 100644 --- a/frontend/src/pages/Settings.tsx +++ b/frontend/src/pages/Settings.tsx @@ -55,10 +55,13 @@ import { useSaveServiceInstance, useServiceInstances, useServiceTypes, + useTestServiceInstance, } from "../hooks/useServices"; +import { ServiceTestPanel } from "../components/ServiceTestPanel"; import type { ServiceInstance, ServiceInstanceInput, + ServiceTestResult, ServiceTypeInfo, } from "../types"; @@ -1435,6 +1438,7 @@ function ServiceConfigEditor({ }) { const saveService = useSaveServiceInstance(); const deleteService = useDeleteServiceInstance(); + const testService = useTestServiceInstance(); const [name, setName] = useState(instance.name); const [enabled, setEnabled] = useState(instance.enabled); const [draftConfig, setDraftConfig] = useState>({ @@ -1442,6 +1446,49 @@ function ServiceConfigEditor({ }); const [draftSecrets, setDraftSecrets] = useState>({}); const [deleteOpen, setDeleteOpen] = useState(false); + const [testResult, setTestResult] = useState(null); + const [saveAnyway, setSaveAnyway] = useState(false); + + // Build a test input from the current editor state. For the test, include + // ALL typed secrets (unfiltered) so the backend can authenticate. + function buildTestInput(): ServiceInstanceInput { + return { + id: instance.id, + service_type: instance.service_type, + name, + config: draftConfig, + secrets: draftSecrets, + enabled, + }; + } + + const testInput = buildTestInput(); + + // Reset test state when the test input changes (store-previous pattern). + const [prevTestInput, setPrevTestInput] = useState(testInput); + if ( + testInput !== prevTestInput && + JSON.stringify(testInput) !== JSON.stringify(prevTestInput) + ) { + setPrevTestInput(testInput); + setTestResult(null); + setSaveAnyway(false); + } + + const testPassed = (testResult?.ok ?? false) || saveAnyway; + + async function handleTest() { + try { + const res = await testService.mutateAsync(testInput); + setTestResult(res); + } catch (err) { + setTestResult({ + ok: false, + detail: err instanceof Error ? err.message : String(err), + evidence: null, + }); + } + } const properties = ( @@ -1560,10 +1607,18 @@ function ServiceConfigEditor({ ))} + +