Kaynağa Gözat

feat: certificates in the WebUI - a page to manage them, a place to assign them

Completes the feature: until now a certificate could only be added with curl.

The Certificates page lists what a project trusts, with the details read out of
the PEM when it was saved - subject, issuer, validity, sha256 fingerprint - and
the expiry shown plainly and flagged 30 days out. An expired anchor is the most
likely explanation for a TLS failure that appears from nowhere, and it is
invisible unless something says so before it happens.

Assignment lives in the workflow's settings rather than on each node, because a
flow talks to a host, not one node at a time, and repeating the same trust
decision on every node is how one of them ends up different from the others.
The section shows each certificate's hosts, since that is what decides whether
it applies at all, and warns - only once something is assigned - that an active
workflow needs publishing for the change to reach triggered runs.

Deleting a certificate that workflows still trust asks first and says how many,
because those workflows refuse to run without it rather than falling back to
running with less trust than their author asked for.

The mTLS identity is a select of Client Certificate credentials, with a note
saying where to create one and why it lives there: it holds a private key.

Checked in a browser against the real backend: the page renders a certificate
with its parsed details and the fingerprint matching openssl; a workflow in a
different project correctly shows none available; one in the same project shows
it, ticks, and persists to settings.certificateIds. tsc, eslint and the build
are all clean.
fszontagh 1 ay önce
ebeveyn
işleme
03c763b8fd

+ 2 - 0
webui/src/App.tsx

@@ -12,6 +12,7 @@ import SettingsPage from './pages/SettingsPage'
 import NodesPage from './pages/NodesPage'
 import DatabasePage from './pages/DatabasePage'
 import CredentialsPage from './pages/CredentialsPage'
+import CertificatesPage from './pages/CertificatesPage'
 import UsersPage from './pages/UsersPage'
 
 function PrivateRoute({ children }: { children: React.ReactNode }) {
@@ -49,6 +50,7 @@ function App() {
         <Route path="executions" element={<ExecutionsPage />} />
         <Route path="nodes" element={<NodesPage />} />
         <Route path="credentials" element={<CredentialsPage />} />
+        <Route path="certificates" element={<CertificatesPage />} />
         <Route path="database" element={<DatabasePage />} />
         <Route path="projects" element={<ProjectsPage />} />
         <Route path="users" element={<UsersPage />} />

+ 116 - 0
webui/src/api/certificates.ts

@@ -0,0 +1,116 @@
+import { api } from './client'
+
+/**
+ * A certificate a workflow can be told to trust.
+ *
+ * Unlike a credential there is no secret here - a certificate is what a server
+ * hands to anyone who connects - so the PEM comes back on every read and the
+ * parsed details are stored beside it rather than being worked out in the
+ * browser. What it shares with a credential is project scoping: trusting an
+ * anchor is a decision about what a project's workflows will believe.
+ */
+export interface Certificate {
+  _id: string
+  name: string
+  description?: string
+  projectId: string
+  /** One certificate, or a bundle when an internal CA is issued under a root. */
+  pem: string
+  /** Exact hosts, or a single leading "*." label. Never a bare "*". */
+  hosts: string[]
+
+  /** Read out of the PEM by the server when it was saved, not typed in beside it. */
+  subject: string
+  issuer: string
+  notBefore: number
+  notAfter: number
+  fingerprintSha256: string
+  isCa: boolean
+  /** Every certificate in the bundle, leaf first. */
+  certificates?: Array<{
+    subject: string
+    issuer: string
+    notBefore: number
+    notAfter: number
+    fingerprintSha256: string
+    isCa: boolean
+  }>
+
+  createdBy?: string
+  ownerId?: string
+  /** How many workflows trust this - what deleting it would break. */
+  usedByWorkflows?: number
+  _created_at?: number
+  _updated_at?: number
+}
+
+export interface CertificateInput {
+  name: string
+  description?: string
+  projectId: string
+  pem: string
+  hosts: string[]
+}
+
+export const certificatesApi = {
+  list: async (projectId?: string): Promise<Certificate[]> => {
+    const response = await api.get('/certificates', {
+      params: projectId ? { projectId } : undefined,
+    })
+    return response.data.certificates || []
+  },
+
+  get: async (id: string): Promise<Certificate> => {
+    const response = await api.get(`/certificates/${id}`)
+    return response.data
+  },
+
+  create: async (input: CertificateInput): Promise<Certificate> => {
+    const response = await api.post('/certificates', input)
+    return response.data
+  },
+
+  update: async (id: string, input: Partial<CertificateInput>): Promise<Certificate> => {
+    const response = await api.put(`/certificates/${id}`, input)
+    return response.data
+  },
+
+  /**
+   * force is refused by default when workflows still trust this, because those
+   * workflows refuse to run without it rather than running with less trust than
+   * their author asked for.
+   */
+  remove: async (id: string, force = false): Promise<void> => {
+    await api.delete(`/certificates/${id}`, { params: force ? { force: 'true' } : undefined })
+  },
+}
+
+/** Milliseconds until expiry; negative once it has passed. 0 notAfter means unknown. */
+export function msUntilExpiry(certificate: Certificate): number | null {
+  if (!certificate.notAfter) return null
+  return certificate.notAfter - Date.now()
+}
+
+/**
+ * How a certificate's validity should read on screen.
+ *
+ * Expiry is the most likely explanation for a TLS failure that appears from
+ * nowhere, so it is shown plainly and flagged before it happens rather than
+ * after.
+ */
+export function expiryState(certificate: Certificate): {
+  label: string
+  tone: 'ok' | 'warn' | 'expired' | 'unknown'
+} {
+  const remaining = msUntilExpiry(certificate)
+  if (remaining === null) return { label: 'validity unknown', tone: 'unknown' }
+
+  const days = Math.floor(remaining / 86400000)
+  if (remaining <= 0) {
+    return { label: `expired ${Math.abs(days)}d ago`, tone: 'expired' }
+  }
+  if (days <= 30) {
+    return { label: `expires in ${days}d`, tone: 'warn' }
+  }
+  return { label: `valid for ${days}d`, tone: 'ok' }
+}

+ 2 - 1
webui/src/components/Layout.tsx

@@ -1,6 +1,6 @@
 import { Outlet, NavLink, useNavigate } from 'react-router-dom'
 import { useAuthStore } from '../stores/authStore'
-import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key, FolderKanban, Users } from 'lucide-react'
+import { Workflow, Play, Settings, LogOut, Menu, Database, Code, Key, FolderKanban, Users, ShieldCheck } from 'lucide-react'
 import { useState, useEffect } from 'react'
 import { useQuery } from '@tanstack/react-query'
 import { useProjectStore } from '../stores/projectStore'
@@ -43,6 +43,7 @@ export default function Layout() {
     { to: '/executions', icon: Play, label: 'Executions' },
     { to: '/nodes', icon: Code, label: 'Nodes' },
     { to: '/credentials', icon: Key, label: 'Credentials' },
+    { to: '/certificates', icon: ShieldCheck, label: 'Certificates' },
     { to: '/projects', icon: FolderKanban, label: 'Projects' },
     { to: '/database', icon: Database, label: 'Database' },
     ...(isAdmin ? [{ to: '/users', icon: Users, label: 'Users' }] : []),

+ 137 - 0
webui/src/components/workflow/WorkflowCertificateSettings.tsx

@@ -0,0 +1,137 @@
+import { useQuery } from '@tanstack/react-query'
+import { ShieldCheck, ExternalLink, AlertTriangle } from 'lucide-react'
+import { Link } from 'react-router-dom'
+import { certificatesApi, expiryState } from '../../api/certificates'
+import { credentialsApi } from '../../api/credentials'
+
+interface Props {
+  /** The workflow's project. Only its own project's certificates can be used. */
+  projectId?: string
+  settings: Record<string, any>
+  onSettingsChange: (settings: Record<string, any>) => void
+}
+
+/**
+ * What this workflow trusts, and what it presents.
+ *
+ * Kept out of the node config on purpose: a flow talks to a host, not one node
+ * at a time, and repeating the same trust decision on every node is how one of
+ * them ends up different from the others.
+ */
+export function WorkflowCertificateSettings({ projectId, settings, onSettingsChange }: Props) {
+  const { data: certificates = [] } = useQuery({
+    queryKey: ['certificates', projectId],
+    queryFn: () => certificatesApi.list(projectId || undefined),
+  })
+
+  const { data: credentialList } = useQuery({
+    queryKey: ['credentials', projectId],
+    queryFn: () => credentialsApi.list(projectId || undefined),
+  })
+
+  const identities = (credentialList?.credentials || []).filter(
+    (c) => c.type === ('client_certificate' as any)
+  )
+  const selected: string[] = settings.certificateIds || []
+
+  const toggle = (id: string) => {
+    const next = selected.includes(id)
+      ? selected.filter((existing) => existing !== id)
+      : [...selected, id]
+    onSettingsChange({ ...settings, certificateIds: next })
+  }
+
+  return (
+    <div>
+      <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
+        <ShieldCheck className="w-4 h-4" />
+        Certificates this workflow trusts
+      </h3>
+
+      {certificates.length === 0 ? (
+        <p className="text-xs text-gray-500 dark:text-gray-400">
+          None in this project yet.{' '}
+          <Link to="/certificates" className="text-primary-600 dark:text-primary-400 inline-flex items-center gap-1">
+            Add one <ExternalLink className="w-3 h-3" />
+          </Link>{' '}
+          to reach a host whose certificate is not publicly trusted, without turning verification
+          off.
+        </p>
+      ) : (
+        <div className="space-y-1.5">
+          {certificates.map((certificate) => {
+            const expiry = expiryState(certificate)
+            const checked = selected.includes(certificate._id)
+            return (
+              <label
+                key={certificate._id}
+                className="flex items-start gap-2 p-2 rounded-lg border border-gray-200 dark:border-slate-700 hover:bg-gray-50 dark:hover:bg-slate-700/40 cursor-pointer"
+              >
+                <input
+                  type="checkbox"
+                  checked={checked}
+                  onChange={() => toggle(certificate._id)}
+                  className="mt-0.5 rounded border-gray-300 dark:border-slate-600 text-primary-600 focus:ring-primary-500"
+                />
+                <span className="min-w-0">
+                  <span className="flex items-center gap-2 flex-wrap">
+                    <span className="text-sm text-gray-800 dark:text-gray-100">{certificate.name}</span>
+                    {expiry.tone === 'expired' && (
+                      <span className="inline-flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
+                        <AlertTriangle className="w-3 h-3" />
+                        {expiry.label}
+                      </span>
+                    )}
+                    {expiry.tone === 'warn' && (
+                      <span className="text-xs text-amber-600 dark:text-amber-400">{expiry.label}</span>
+                    )}
+                  </span>
+                  <span className="block text-xs text-gray-500 dark:text-gray-400 font-mono truncate">
+                    {(certificate.hosts || []).join(', ')}
+                  </span>
+                </span>
+              </label>
+            )
+          })}
+        </div>
+      )}
+
+      <p className="mt-2 text-xs text-gray-500 dark:text-gray-400">
+        Each applies only to the hosts it names. Every other host this workflow contacts keeps
+        using the public trust store, and the chain and hostname are still checked either way.
+      </p>
+
+      <div className="mt-4">
+        <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">
+          <ShieldCheck className="w-4 h-4" />
+          Identity this workflow presents
+        </h3>
+        <select
+          value={settings.clientCertificateId || ''}
+          onChange={(e) => onSettingsChange({ ...settings, clientCertificateId: e.target.value })}
+          className="w-full px-3 py-2 text-sm border border-gray-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-900 text-gray-900 dark:text-gray-100"
+        >
+          <option value="">None</option>
+          {identities.map((identity: any) => (
+            <option key={identity.id} value={identity.id}>
+              {identity.name}
+            </option>
+          ))}
+        </select>
+        <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">
+          For a server that asks the caller to prove who it is (mTLS). Create one as a Client
+          Certificate credential - it holds a private key, so it lives with the credentials rather
+          than here.
+        </p>
+      </div>
+
+      {(selected.length > 0 || settings.clientCertificateId) && (
+        <p className="mt-3 text-xs text-amber-700 dark:text-amber-400">
+          A workflow that names a certificate which has been deleted refuses to run rather than
+          running with less trust than you asked for. If this workflow is active, publish it for
+          the change to reach triggered runs.
+        </p>
+      )}
+    </div>
+  )
+}

+ 10 - 0
webui/src/components/workflow/WorkflowSettingsModal.tsx

@@ -3,9 +3,12 @@ import { useQuery } from '@tanstack/react-query'
 import { workflowsApi } from '../../api/workflows'
 import { WorkflowStorageSettings } from './WorkflowStorageSettings'
 import { WorkflowRetentionSettings } from './WorkflowRetentionSettings'
+import { WorkflowCertificateSettings } from './WorkflowCertificateSettings'
 
 interface WorkflowSettingsModalProps {
   workflowId?: string
+  /** The workflow's project - a workflow may only trust its own project's certificates. */
+  projectId?: string
   settings: Record<string, any>
   hasChanges: boolean
   isSaving: boolean
@@ -16,6 +19,7 @@ interface WorkflowSettingsModalProps {
 
 export function WorkflowSettingsModal({
   workflowId,
+  projectId,
   settings,
   hasChanges,
   isSaving,
@@ -71,6 +75,12 @@ export function WorkflowSettingsModal({
               </div>
             </div>
 
+            <WorkflowCertificateSettings
+              projectId={projectId}
+              settings={settings}
+              onSettingsChange={onSettingsChange}
+            />
+
             {/* Error workflow */}
             <div>
               <h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2 flex items-center gap-2">

+ 411 - 0
webui/src/pages/CertificatesPage.tsx

@@ -0,0 +1,411 @@
+import { useState, useMemo } from 'react'
+import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
+import { format } from 'date-fns'
+import {
+  ShieldCheck,
+  Plus,
+  Trash2,
+  Edit3,
+  Search,
+  X,
+  AlertTriangle,
+  Globe,
+} from 'lucide-react'
+import { useProjectStore } from '../stores/projectStore'
+import { projectsApi } from '../api/users'
+import { certificatesApi, Certificate, expiryState } from '../api/certificates'
+import { ConfirmModal } from '../components/workflow/ConfirmModal'
+
+function safeFormatDate(timestamp: number, formatStr = 'yyyy-MM-dd'): string {
+  if (!timestamp || timestamp < 0 || timestamp > 8640000000000000) return 'unknown'
+  try {
+    return format(new Date(timestamp), formatStr)
+  } catch {
+    return 'unknown'
+  }
+}
+
+const TONE_CLASS: Record<string, string> = {
+  ok: 'text-green-700 dark:text-green-400 bg-green-100 dark:bg-green-900/30',
+  warn: 'text-amber-700 dark:text-amber-400 bg-amber-100 dark:bg-amber-900/30',
+  expired: 'text-red-700 dark:text-red-400 bg-red-100 dark:bg-red-900/30',
+  unknown: 'text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-slate-700',
+}
+
+interface FormState {
+  id?: string
+  name: string
+  description: string
+  projectId: string
+  pem: string
+  hosts: string
+}
+
+const EMPTY_FORM: FormState = { name: '', description: '', projectId: '', pem: '', hosts: '' }
+
+export default function CertificatesPage() {
+  const projectId = useProjectStore((s) => s.projectId)
+  const queryClient = useQueryClient()
+  const [search, setSearch] = useState('')
+  const [form, setForm] = useState<FormState | null>(null)
+  const [formError, setFormError] = useState<string | null>(null)
+  const [toDelete, setToDelete] = useState<Certificate | null>(null)
+
+  const { data: certificates = [], isLoading } = useQuery({
+    queryKey: ['certificates', projectId],
+    queryFn: () => certificatesApi.list(projectId || undefined),
+  })
+
+  const { data: projects = [] } = useQuery({
+    queryKey: ['projects'],
+    queryFn: () => projectsApi.list(),
+  })
+
+  const invalidate = () => queryClient.invalidateQueries({ queryKey: ['certificates'] })
+
+  const saveMutation = useMutation({
+    mutationFn: (state: FormState) => {
+      const input = {
+        name: state.name.trim(),
+        description: state.description.trim(),
+        projectId: state.projectId,
+        pem: state.pem,
+        // One per line or comma separated - whichever the person typing finds
+        // natural. Blank entries are dropped rather than refused.
+        hosts: state.hosts
+          .split(/[\n,]/)
+          .map((h) => h.trim())
+          .filter(Boolean),
+      }
+      return state.id
+        ? certificatesApi.update(state.id, input)
+        : certificatesApi.create(input)
+    },
+    onSuccess: () => {
+      invalidate()
+      setForm(null)
+      setFormError(null)
+    },
+    onError: (error: any) => {
+      // The server explains exactly what it refused and why - an unparsable
+      // PEM, a wildcard in the wrong place - so show that rather than a
+      // generic failure.
+      setFormError(error?.response?.data?.error || error?.message || 'Could not save the certificate')
+    },
+  })
+
+  const deleteMutation = useMutation({
+    mutationFn: ({ id, force }: { id: string; force: boolean }) => certificatesApi.remove(id, force),
+    onSuccess: () => {
+      invalidate()
+      setToDelete(null)
+    },
+  })
+
+  const visible = useMemo(() => {
+    const term = search.trim().toLowerCase()
+    if (!term) return certificates
+    return certificates.filter(
+      (c) =>
+        c.name.toLowerCase().includes(term) ||
+        c.subject?.toLowerCase().includes(term) ||
+        c.hosts?.some((h) => h.toLowerCase().includes(term))
+    )
+  }, [certificates, search])
+
+  const openNew = () =>
+    setForm({ ...EMPTY_FORM, projectId: projectId || projects[0]?._id || '' })
+
+  const openEdit = (certificate: Certificate) =>
+    setForm({
+      id: certificate._id,
+      name: certificate.name,
+      description: certificate.description || '',
+      projectId: certificate.projectId,
+      pem: certificate.pem,
+      hosts: (certificate.hosts || []).join('\n'),
+    })
+
+  return (
+    <div className="h-full overflow-auto p-6">
+      <div className="flex items-center justify-between mb-2">
+        <div className="flex items-center gap-2">
+          <ShieldCheck className="w-6 h-6 text-gray-600 dark:text-gray-400" />
+          <h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">Certificates</h1>
+        </div>
+        <button
+          onClick={openNew}
+          className="flex items-center gap-2 px-3 py-2 bg-primary-600 hover:bg-primary-700 text-white rounded-lg text-sm"
+        >
+          <Plus className="w-4 h-4" />
+          Add certificate
+        </button>
+      </div>
+
+      <p className="text-sm text-gray-600 dark:text-gray-400 mb-4 max-w-3xl">
+        Certificates a workflow can be told to trust, so a host with a self-signed or
+        privately-issued certificate is reachable with verification still on. Assign them in a
+        workflow's settings; each one applies only to the hosts named here, and every other host
+        keeps using the public trust store.
+      </p>
+
+      <div className="relative mb-4 max-w-md">
+        <Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
+        <input
+          value={search}
+          onChange={(e) => setSearch(e.target.value)}
+          placeholder="Search by name, host or subject"
+          className="w-full pl-9 pr-3 py-2 text-sm bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-700 dark:text-gray-200"
+        />
+      </div>
+
+      {isLoading ? (
+        <p className="text-gray-500 dark:text-gray-400">Loading...</p>
+      ) : visible.length === 0 ? (
+        <div className="border border-dashed border-gray-300 dark:border-slate-700 rounded-lg p-8 text-center">
+          <ShieldCheck className="w-8 h-8 mx-auto text-gray-400 mb-2" />
+          <p className="text-gray-600 dark:text-gray-400">
+            {certificates.length === 0
+              ? 'No certificates yet. Add one to reach a host whose certificate is not publicly trusted.'
+              : 'Nothing matches that search.'}
+          </p>
+        </div>
+      ) : (
+        <div className="grid gap-3">
+          {visible.map((certificate) => {
+            const expiry = expiryState(certificate)
+            return (
+              <div
+                key={certificate._id}
+                className="bg-white dark:bg-slate-800 border border-gray-200 dark:border-slate-700 rounded-lg p-4"
+              >
+                <div className="flex items-start justify-between gap-4">
+                  <div className="min-w-0">
+                    <div className="flex items-center gap-2 flex-wrap">
+                      <span className="font-semibold text-gray-900 dark:text-gray-100">
+                        {certificate.name}
+                      </span>
+                      <span className={`px-2 py-0.5 rounded-full text-xs font-medium ${TONE_CLASS[expiry.tone]}`}>
+                        {expiry.label}
+                      </span>
+                      {certificate.isCa && (
+                        <span className="px-2 py-0.5 rounded-full text-xs bg-gray-100 dark:bg-slate-700 text-gray-600 dark:text-gray-300">
+                          CA
+                        </span>
+                      )}
+                      {(certificate.usedByWorkflows ?? 0) > 0 && (
+                        <span className="px-2 py-0.5 rounded-full text-xs bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-400">
+                          trusted by {certificate.usedByWorkflows} workflow
+                          {certificate.usedByWorkflows === 1 ? '' : 's'}
+                        </span>
+                      )}
+                    </div>
+
+                    {certificate.description && (
+                      <p className="text-sm text-gray-600 dark:text-gray-400 mt-1">
+                        {certificate.description}
+                      </p>
+                    )}
+
+                    <div className="flex items-center gap-1.5 mt-2 flex-wrap">
+                      <Globe className="w-3.5 h-3.5 text-gray-400" />
+                      {(certificate.hosts || []).map((host) => (
+                        <span
+                          key={host}
+                          className="px-2 py-0.5 rounded text-xs font-mono bg-gray-100 dark:bg-slate-700 text-gray-700 dark:text-gray-300"
+                        >
+                          {host}
+                        </span>
+                      ))}
+                    </div>
+
+                    <dl className="mt-3 text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
+                      <div className="flex gap-2">
+                        <dt className="w-16 shrink-0">Subject</dt>
+                        <dd className="font-mono break-all">{certificate.subject || 'unknown'}</dd>
+                      </div>
+                      <div className="flex gap-2">
+                        <dt className="w-16 shrink-0">Issuer</dt>
+                        <dd className="font-mono break-all">{certificate.issuer || 'unknown'}</dd>
+                      </div>
+                      <div className="flex gap-2">
+                        <dt className="w-16 shrink-0">Valid</dt>
+                        <dd>
+                          {safeFormatDate(certificate.notBefore)} to{' '}
+                          {safeFormatDate(certificate.notAfter)}
+                        </dd>
+                      </div>
+                      <div className="flex gap-2">
+                        <dt className="w-16 shrink-0">SHA-256</dt>
+                        <dd className="font-mono break-all">{certificate.fingerprintSha256}</dd>
+                      </div>
+                    </dl>
+                  </div>
+
+                  <div className="flex items-center gap-1 shrink-0">
+                    <button
+                      onClick={() => openEdit(certificate)}
+                      title="Edit"
+                      className="p-2 text-gray-500 hover:text-gray-800 dark:hover:text-gray-200 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700"
+                    >
+                      <Edit3 className="w-4 h-4" />
+                    </button>
+                    <button
+                      onClick={() => setToDelete(certificate)}
+                      title="Delete"
+                      className="p-2 text-red-500 hover:text-red-700 rounded-lg hover:bg-red-50 dark:hover:bg-red-900/20"
+                    >
+                      <Trash2 className="w-4 h-4" />
+                    </button>
+                  </div>
+                </div>
+              </div>
+            )
+          })}
+        </div>
+      )}
+
+      {form && (
+        <div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
+          <div className="bg-white dark:bg-slate-800 rounded-lg w-full max-w-2xl max-h-[90vh] overflow-auto">
+            <div className="flex items-center justify-between px-5 py-3 border-b border-gray-200 dark:border-slate-700">
+              <h2 className="font-semibold text-gray-900 dark:text-gray-100">
+                {form.id ? 'Edit certificate' : 'Add certificate'}
+              </h2>
+              <button onClick={() => { setForm(null); setFormError(null) }} className="p-1 text-gray-500">
+                <X className="w-5 h-5" />
+              </button>
+            </div>
+
+            <div className="p-5 space-y-4">
+              <div>
+                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                  Name
+                </label>
+                <input
+                  value={form.name}
+                  onChange={(e) => setForm({ ...form, name: e.target.value })}
+                  placeholder="Internal CA"
+                  className="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-800 dark:text-gray-100"
+                />
+              </div>
+
+              <div>
+                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                  Project
+                </label>
+                <select
+                  value={form.projectId}
+                  onChange={(e) => setForm({ ...form, projectId: e.target.value })}
+                  disabled={!!form.id}
+                  className="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-800 dark:text-gray-100 disabled:opacity-60"
+                >
+                  <option value="">Choose a project</option>
+                  {projects.map((project) => (
+                    <option key={project._id} value={project._id}>
+                      {project.name}
+                    </option>
+                  ))}
+                </select>
+                {form.id && (
+                  <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+                    A certificate does not move between projects - who can use it would change.
+                  </p>
+                )}
+              </div>
+
+              <div>
+                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                  Hosts
+                </label>
+                <textarea
+                  value={form.hosts}
+                  onChange={(e) => setForm({ ...form, hosts: e.target.value })}
+                  rows={3}
+                  placeholder={'internal.example.com\n*.example.com'}
+                  className="w-full px-3 py-2 text-sm font-mono bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-800 dark:text-gray-100"
+                />
+                <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+                  One per line. A wildcard is allowed only as a single leading label
+                  (<code>*.example.com</code> covers <code>api.example.com</code>, not
+                  <code> example.com</code>). This certificate is used for these hosts and no
+                  others.
+                </p>
+              </div>
+
+              <div>
+                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                  Certificate (PEM)
+                </label>
+                <textarea
+                  value={form.pem}
+                  onChange={(e) => setForm({ ...form, pem: e.target.value })}
+                  rows={8}
+                  placeholder="-----BEGIN CERTIFICATE-----"
+                  className="w-full px-3 py-2 text-xs font-mono bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-800 dark:text-gray-100"
+                />
+                <p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
+                  Paste the server's certificate, or the CA that issued it. The details are read
+                  from it when you save; anything that is not a certificate is refused. A private
+                  key does not belong here - that is a Client Certificate credential.
+                </p>
+              </div>
+
+              <div>
+                <label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
+                  Description
+                </label>
+                <input
+                  value={form.description}
+                  onChange={(e) => setForm({ ...form, description: e.target.value })}
+                  placeholder="Optional - where it came from, who to ask about it"
+                  className="w-full px-3 py-2 text-sm bg-white dark:bg-slate-900 border border-gray-200 dark:border-slate-700 rounded-lg text-gray-800 dark:text-gray-100"
+                />
+              </div>
+
+              {formError && (
+                <div className="flex items-start gap-2 p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-900 rounded-lg">
+                  <AlertTriangle className="w-4 h-4 text-red-600 dark:text-red-400 mt-0.5 shrink-0" />
+                  <p className="text-sm text-red-700 dark:text-red-300">{formError}</p>
+                </div>
+              )}
+            </div>
+
+            <div className="flex justify-end gap-2 px-5 py-3 border-t border-gray-200 dark:border-slate-700">
+              <button
+                onClick={() => { setForm(null); setFormError(null) }}
+                className="px-3 py-2 text-sm text-gray-700 dark:text-gray-300 rounded-lg hover:bg-gray-100 dark:hover:bg-slate-700"
+              >
+                Cancel
+              </button>
+              <button
+                onClick={() => saveMutation.mutate(form)}
+                disabled={saveMutation.isPending || !form.name.trim() || !form.pem.trim() || !form.projectId}
+                className="px-3 py-2 text-sm bg-primary-600 hover:bg-primary-700 disabled:opacity-50 text-white rounded-lg"
+              >
+                {saveMutation.isPending ? 'Saving...' : 'Save'}
+              </button>
+            </div>
+          </div>
+        </div>
+      )}
+
+      {toDelete && (
+        <ConfirmModal
+          title="Delete certificate?"
+          message={
+            (toDelete.usedByWorkflows ?? 0) > 0
+              ? `"${toDelete.name}" is trusted by ${toDelete.usedByWorkflows} workflow(s).\n\nThose workflows will refuse to run until you remove it from their settings - they do not fall back to running without it. Delete it anyway?`
+              : `Delete "${toDelete.name}"? Any workflow that names it afterwards will refuse to run.`
+          }
+          confirmLabel="Delete certificate"
+          tone="danger"
+          onConfirm={() =>
+            deleteMutation.mutate({ id: toDelete._id, force: (toDelete.usedByWorkflows ?? 0) > 0 })
+          }
+          onCancel={() => setToDelete(null)}
+        />
+      )}
+    </div>
+  )
+}

+ 1 - 0
webui/src/pages/WorkflowEditorPage.tsx

@@ -3514,6 +3514,7 @@ function WorkflowEditorInner() {
       {showWorkflowSettings && (
         <WorkflowSettingsModal
           workflowId={id}
+          projectId={workflow?.projectId}
           settings={workflowSettings}
           hasChanges={hasChanges}
           isSaving={saveMutation.isPending}