diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index cce4434..8fbe827 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -2,12 +2,14 @@ package catalog // Template represents a preconfigured service template in the catalog. type Template struct { - ID string `json:"id"` - Name string `json:"name"` - Host string `json:"host"` - Description string `json:"description"` - AuthType string `json:"auth_type"` + ID string `json:"id"` + Name string `json:"name"` + Host string `json:"host"` + Description string `json:"description"` + AuthType string `json:"auth_type"` SuggestedCredentialKey string `json:"suggested_credential_key"` + Header string `json:"header,omitempty"` + Prefix string `json:"prefix,omitempty"` } // catalog is the built-in list of common service templates. @@ -15,13 +17,13 @@ var catalog = []Template{ {ID: "stripe", Name: "Stripe", Host: "api.stripe.com", Description: "Payment processing API", AuthType: "bearer", SuggestedCredentialKey: "STRIPE_KEY"}, {ID: "github", Name: "GitHub", Host: "api.github.com", Description: "GitHub REST API", AuthType: "bearer", SuggestedCredentialKey: "GITHUB_TOKEN"}, {ID: "openai", Name: "OpenAI", Host: "api.openai.com", Description: "OpenAI / ChatGPT API", AuthType: "bearer", SuggestedCredentialKey: "OPENAI_API_KEY"}, - {ID: "anthropic", Name: "Anthropic", Host: "api.anthropic.com", Description: "Claude API", AuthType: "api-key", SuggestedCredentialKey: "ANTHROPIC_API_KEY"}, + {ID: "anthropic", Name: "Anthropic", Host: "api.anthropic.com", Description: "Claude API", AuthType: "api-key", SuggestedCredentialKey: "ANTHROPIC_API_KEY", Header: "x-api-key"}, {ID: "slack", Name: "Slack", Host: "slack.com", Description: "Slack Web API", AuthType: "bearer", SuggestedCredentialKey: "SLACK_TOKEN"}, {ID: "twilio", Name: "Twilio", Host: "api.twilio.com", Description: "Communication APIs (SMS, voice, email)", AuthType: "basic", SuggestedCredentialKey: "TWILIO_AUTH_TOKEN"}, {ID: "sendgrid", Name: "SendGrid", Host: "api.sendgrid.com", Description: "Email delivery API", AuthType: "bearer", SuggestedCredentialKey: "SENDGRID_API_KEY"}, {ID: "aws-s3", Name: "AWS S3", Host: "s3.amazonaws.com", Description: "Amazon S3 object storage", AuthType: "custom", SuggestedCredentialKey: "AWS_SECRET_ACCESS_KEY"}, {ID: "cloudflare", Name: "Cloudflare", Host: "api.cloudflare.com", Description: "Cloudflare API", AuthType: "bearer", SuggestedCredentialKey: "CLOUDFLARE_API_TOKEN"}, - {ID: "datadog", Name: "Datadog", Host: "api.datadoghq.com", Description: "Monitoring and analytics", AuthType: "api-key", SuggestedCredentialKey: "DATADOG_API_KEY"}, + {ID: "datadog", Name: "Datadog", Host: "api.datadoghq.com", Description: "Monitoring and analytics", AuthType: "api-key", SuggestedCredentialKey: "DATADOG_API_KEY", Header: "DD-API-KEY"}, {ID: "pagerduty", Name: "PagerDuty", Host: "api.pagerduty.com", Description: "Incident management", AuthType: "bearer", SuggestedCredentialKey: "PAGERDUTY_TOKEN"}, {ID: "linear", Name: "Linear", Host: "api.linear.app", Description: "Project management and issue tracking", AuthType: "bearer", SuggestedCredentialKey: "LINEAR_API_KEY"}, {ID: "jira", Name: "Jira", Host: "*.atlassian.net", Description: "Atlassian Jira project tracking", AuthType: "basic", SuggestedCredentialKey: "JIRA_API_TOKEN"}, @@ -29,9 +31,9 @@ var catalog = []Template{ {ID: "vercel", Name: "Vercel", Host: "api.vercel.com", Description: "Vercel deployment platform", AuthType: "bearer", SuggestedCredentialKey: "VERCEL_TOKEN"}, {ID: "supabase", Name: "Supabase", Host: "*.supabase.co", Description: "Supabase backend-as-a-service", AuthType: "bearer", SuggestedCredentialKey: "SUPABASE_KEY"}, {ID: "resend", Name: "Resend", Host: "api.resend.com", Description: "Email API for developers", AuthType: "bearer", SuggestedCredentialKey: "RESEND_API_KEY"}, - {ID: "postmark", Name: "Postmark", Host: "api.postmarkapp.com", Description: "Transactional email service", AuthType: "api-key", SuggestedCredentialKey: "POSTMARK_SERVER_TOKEN"}, + {ID: "postmark", Name: "Postmark", Host: "api.postmarkapp.com", Description: "Transactional email service", AuthType: "api-key", SuggestedCredentialKey: "POSTMARK_SERVER_TOKEN", Header: "X-Postmark-Server-Token"}, {ID: "sentry", Name: "Sentry", Host: "sentry.io", Description: "Error tracking and performance monitoring", AuthType: "bearer", SuggestedCredentialKey: "SENTRY_AUTH_TOKEN"}, - {ID: "shopify", Name: "Shopify", Host: "*.myshopify.com", Description: "Shopify e-commerce API", AuthType: "api-key", SuggestedCredentialKey: "SHOPIFY_ACCESS_TOKEN"}, + {ID: "shopify", Name: "Shopify", Host: "*.myshopify.com", Description: "Shopify e-commerce API", AuthType: "api-key", SuggestedCredentialKey: "SHOPIFY_ACCESS_TOKEN", Header: "X-Shopify-Access-Token"}, } // GetAll returns all available service templates. diff --git a/web/src/pages/vault/ServicesTab.tsx b/web/src/pages/vault/ServicesTab.tsx index 0003be4..b53a0f6 100644 --- a/web/src/pages/vault/ServicesTab.tsx +++ b/web/src/pages/vault/ServicesTab.tsx @@ -10,9 +10,10 @@ import Modal from "../../components/Modal"; import Button from "../../components/Button"; import Input from "../../components/Input"; import FormField from "../../components/FormField"; +import Select from "../../components/Select"; import Toggle from "../../components/Toggle"; import { type Auth, AUTH_TYPE_LABELS } from "../../components/ProposalPreview"; -import { apiFetch } from "../../lib/api"; +import { apiFetch, apiRequest } from "../../lib/api"; interface Service { host: string; @@ -21,6 +22,17 @@ interface Service { auth: Auth; } +interface CatalogTemplate { + id: string; + name: string; + host: string; + description: string; + auth_type: string; + suggested_credential_key: string; + header?: string; + prefix?: string; +} + function isEnabled(service: Service): boolean { return service.enabled !== false; } @@ -38,6 +50,7 @@ export default function ServicesTab() { const [services, setServices] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); + const [catalog, setCatalog] = useState([]); // Add/Edit modal state: null = closed, -1 = add, 0+ = edit index const [editingIndex, setEditingIndex] = useState(null); @@ -49,8 +62,20 @@ export default function ServicesTab() { useEffect(() => { fetchServices(); + fetchCatalog(); }, []); + async function fetchCatalog() { + try { + const data = await apiRequest<{ services: CatalogTemplate[] }>("/v1/service-catalog"); + const entries = data.services ?? []; + entries.sort((a, b) => a.name.localeCompare(b.name)); + setCatalog(entries); + } catch { + // Catalog is optional — degrade silently to manual entry. + } + } + async function fetchServices() { try { const resp = await apiFetch( @@ -263,6 +288,7 @@ export default function ServicesTab() { = 0 ? services[editingIndex] : undefined} + catalog={catalog} onClose={() => setEditingIndex(null)} onSave={async (service) => { const updated = [...services]; @@ -285,11 +311,13 @@ export default function ServicesTab() { function ServiceModal({ title, initial, + catalog, onClose, onSave, }: { title: string; initial?: Service; + catalog: CatalogTemplate[]; onClose: () => void; onSave: (service: Service) => Promise; }) { @@ -318,6 +346,45 @@ function ServiceModal({ return [{ name: "", value: "" }]; }); + // Snapshot the catalog at open time so a fetch resolving mid-form doesn't + // shift the preset picker into view above fields the user is already editing. + const [catalogSnapshot] = useState(() => catalog); + const [selectedPreset, setSelectedPreset] = useState(""); + const showPresets = !initial && catalogSnapshot.length > 0; + + function resetFields() { + setHost(""); + setDescription(""); + setAuthType("bearer"); + setToken(""); + setUsername(""); + setPassword(""); + setApiKey(""); + setApiKeyHeader(""); + setApiKeyPrefix(""); + setCustomHeaders([{ name: "", value: "" }]); + } + + function applyPreset(id: string) { + setSelectedPreset(id); + resetFields(); + if (!id) return; + const tpl = catalogSnapshot.find((t) => t.id === id); + if (!tpl) return; + setHost(tpl.host); + setDescription(tpl.description); + setAuthType(tpl.auth_type); + if (tpl.auth_type === "bearer") setToken(tpl.suggested_credential_key); + // Catalogued basic-auth services (Twilio, Jira) carry a token that belongs + // in the password slot — the username (AccountSID, email) is user-specific. + if (tpl.auth_type === "basic") setPassword(tpl.suggested_credential_key); + if (tpl.auth_type === "api-key") { + setApiKey(tpl.suggested_credential_key); + setApiKeyHeader(tpl.header ?? ""); + setApiKeyPrefix(tpl.prefix ?? ""); + } + } + const [saving, setSaving] = useState(false); const [error, setError] = useState(""); @@ -409,6 +476,24 @@ function ServiceModal({ } >
+ {showPresets && ( + + + + )}