Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 11 additions & 9 deletions internal/catalog/catalog.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,36 +2,38 @@ 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.
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"},
{ID: "notion", Name: "Notion", Host: "api.notion.com", Description: "Notion workspace API", AuthType: "bearer", SuggestedCredentialKey: "NOTION_TOKEN"},
{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.
Expand Down
87 changes: 86 additions & 1 deletion web/src/pages/vault/ServicesTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
}
Expand All @@ -38,6 +50,7 @@ export default function ServicesTab() {
const [services, setServices] = useState<Service[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [catalog, setCatalog] = useState<CatalogTemplate[]>([]);

// Add/Edit modal state: null = closed, -1 = add, 0+ = edit index
const [editingIndex, setEditingIndex] = useState<number | null>(null);
Expand All @@ -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(
Expand Down Expand Up @@ -263,6 +288,7 @@ export default function ServicesTab() {
<ServiceModal
title={editingIndex === -1 ? "Add Service" : "Edit Service"}
initial={editingIndex >= 0 ? services[editingIndex] : undefined}
catalog={catalog}
onClose={() => setEditingIndex(null)}
onSave={async (service) => {
const updated = [...services];
Expand All @@ -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<void>;
}) {
Expand Down Expand Up @@ -318,6 +346,45 @@ function ServiceModal({
return [{ name: "", value: "" }];
});

Comment thread
dangtony98 marked this conversation as resolved.
// 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<CatalogTemplate[]>(() => 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);
Comment thread
dangtony98 marked this conversation as resolved.
const [error, setError] = useState("");

Expand Down Expand Up @@ -409,6 +476,24 @@ function ServiceModal({
}
>
<div className="space-y-4">
{showPresets && (
<FormField
label="Start from preset"
helperText="Pick a catalogued service to pre-fill the form, or leave as Custom to configure manually."
>
<Select
value={selectedPreset}
onChange={(e) => applyPreset(e.target.value)}
>
<option value="">Custom (blank)</option>
{catalogSnapshot.map((tpl) => (
<option key={tpl.id} value={tpl.id}>
{tpl.name} — {tpl.host}
</option>
))}
</Select>
</FormField>
)}
<FormField label="Host Pattern">
<Input
placeholder="e.g. api.stripe.com"
Expand Down