|
|
@@ -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>
|
|
|
+ )
|
|
|
+}
|