import { useEffect, useState } from 'react'; import { useQuery } from '@tanstack/react-query'; import { useTrackedMutation } from '../hooks/useTrackedMutation'; import { getRenewalPolicies, createRenewalPolicy, updateRenewalPolicy, deleteRenewalPolicy, } from '../api/client'; import PageHeader from '../components/PageHeader'; import DataTable from '../components/DataTable'; import type { Column } from '../components/DataTable'; import ErrorState from '../components/ErrorState'; import { formatDateTime } from '../api/utils'; import type { RenewalPolicy } from '../api/types'; // RenewalPoliciesPage — B-1 master closure (cat-b-4631ca092bee). // Pre-B-1 the backend had full CRUD at /api/v1/renewal-policies but // there was no GUI page. Operators wanting to edit the seeded // `rp-default` policy or create custom `rp-*` policies for short-lived // certs had to go through `psql` directly. This page exposes the table // + Create + Edit + Delete affordances. Renewal policies are referenced // by managed certificates via `renewal_policy_id`; the backend's // repository.ErrRenewalPolicyInUse sentinel surfaces a 409 on Delete // when a policy still has cert references — surfaced as an alert here. // // Field set per `internal/domain/certificate.go::RenewalPolicy`: // - renewal_window_days: int (when to start renewal — usually 30) // - auto_renew: bool (whether the scheduler renews automatically) // - max_retries: int // - retry_interval_seconds: int (post-U-3 column rename; // cat-o-retry_interval_unit_mismatch closed) // - alert_thresholds_days: int[] (notification days before expiry) interface PolicyFormFields { name: string; renewal_window_days: number; auto_renew: boolean; max_retries: number; retry_interval_seconds: number; alert_thresholds_days: number[]; } function defaultFields(): PolicyFormFields { return { name: '', renewal_window_days: 30, auto_renew: true, max_retries: 3, retry_interval_seconds: 60, alert_thresholds_days: [30, 14, 7, 0], }; } function policyToFields(p: RenewalPolicy): PolicyFormFields { return { name: p.name, renewal_window_days: p.renewal_window_days, auto_renew: p.auto_renew, max_retries: p.max_retries, retry_interval_seconds: p.retry_interval_seconds, alert_thresholds_days: p.alert_thresholds_days || [], }; } // PolicyFormModal — shared scaffolding for Create + Edit. The only // shape difference between the two flows is which mutationFn the // caller supplies + the modal title; everything else mirrors. interface PolicyFormModalProps { title: string; initial: PolicyFormFields; isOpen: boolean; onClose: () => void; onSubmit: (fields: PolicyFormFields) => void; isSaving: boolean; error: string | null; } function PolicyFormModal({ title, initial, isOpen, onClose, onSubmit, isSaving, error }: PolicyFormModalProps) { const [fields, setFields] = useState(initial); useEffect(() => { if (isOpen) setFields(initial); }, [isOpen, initial]); if (!isOpen) return null; const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); if (!fields.name.trim()) return; onSubmit({ ...fields, name: fields.name.trim() }); }; return (
e.stopPropagation()}>

{title}

{error &&
{error}
}
setFields({ ...fields, name: e.target.value })} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" placeholder="e.g., Standard 30-day" required />
setFields({ ...fields, renewal_window_days: Number(e.target.value) })} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" min={1} />
setFields({ ...fields, max_retries: Number(e.target.value) })} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" min={0} />
setFields({ ...fields, retry_interval_seconds: Number(e.target.value) })} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" min={0} />
{ const parts = e.target.value .split(',') .map(s => Number(s.trim())) .filter(n => !isNaN(n)); setFields({ ...fields, alert_thresholds_days: parts }); }} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" placeholder="30, 14, 7, 0" />
); } export default function RenewalPoliciesPage() { const [showCreate, setShowCreate] = useState(false); const [editing, setEditing] = useState(null); const { data, isLoading, error, refetch } = useQuery({ queryKey: ['renewal-policies'], queryFn: () => getRenewalPolicies(), }); const createMutation = useTrackedMutation({ mutationFn: createRenewalPolicy, invalidates: [['renewal-policies']], onSuccess: () => { setShowCreate(false); }, }); const updateMutation = useTrackedMutation({ mutationFn: ({ id, data }: { id: string; data: Partial }) => updateRenewalPolicy(id, data), invalidates: [['renewal-policies']], onSuccess: () => { setEditing(null); }, }); const deleteMutation = useTrackedMutation({ mutationFn: deleteRenewalPolicy, invalidates: [['renewal-policies']], // Backend surfaces ErrRenewalPolicyInUse as a 409. We surface as an // alert so the operator sees "this policy is still attached to N // certificates" and can re-target those certs to another policy // before deleting. onError: (err: Error) => alert(`Delete failed: ${err.message}`), }); const columns: Column[] = [ { key: 'name', label: 'Policy', render: (p) => (
{p.name}
{p.id}
), }, { key: 'window', label: 'Renewal Window', render: (p) => {p.renewal_window_days} days, }, { key: 'auto_renew', label: 'Auto', render: (p) => ( {p.auto_renew ? 'on' : 'manual'} ), }, { key: 'retries', label: 'Retries', render: (p) => {p.max_retries}× / {p.retry_interval_seconds}s, }, { key: 'alerts', label: 'Alert Thresholds', render: (p) => ( {(p.alert_thresholds_days || []).join(', ') || '—'} ), }, { key: 'created', label: 'Created', render: (p) => {formatDateTime(p.created_at)}, }, { key: 'actions', label: '', render: (p) => (
), }, ]; return ( <> setShowCreate(true)} className="btn btn-primary"> + New Policy } />
{error ? ( refetch()} /> ) : ( )}
setShowCreate(false)} onSubmit={(fields) => createMutation.mutate(fields)} isSaving={createMutation.isPending} error={createMutation.error ? (createMutation.error as Error).message : null} /> setEditing(null)} onSubmit={(fields) => { if (!editing) return; updateMutation.mutate({ id: editing.id, data: fields }); }} isSaving={updateMutation.isPending} error={updateMutation.error ? (updateMutation.error as Error).message : null} /> ); }