feat: frontend audit fixes, README accuracy pass, doc updates

Frontend audit (10 categories): lifecycle fields in types, new API
functions (CRL, OCSP, deployments, updateIssuer/Target, getPolicy),
issuer/owner/profile filters on CertificatesPage, last_renewal_at
column, error_message column on JobsPage, full crypto policy UI on
ProfilesPage (key algorithms, EKUs, SAN patterns), key info + CA
badge on DiscoveryPage, edit modal on TargetDetailPage, tags field
on certificate creation, darwin→macOS mapping on AgentFleetPage.
211 Vitest tests passing.

README accuracy: test counts (1300+ Go, 211 frontend), page count
(24), demo data (32 certs, 7 issuers, 180 days), endpoint count
(97), MCP tools (80), CLI subcommands (10), moved shipped items
out of "Coming in v2.1.0".

Docs: architecture.md diagrams updated (Vault PKI, DigiCert,
Traefik, Caddy added), features.md Vault/DigiCert status updated.
Version bumped to v2.0.20. cli binary removed from git tracking.
Testing guide Part 41 added (12 auto + 9 manual tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-30 22:10:45 -04:00
parent 836534f2a7
commit 6c8d4eca40
16 changed files with 638 additions and 52 deletions
+55
View File
@@ -83,6 +83,12 @@ import {
getIssuer,
getTarget,
getPrometheusMetrics,
getCertificateDeployments,
getCRL,
getOCSPStatus,
updateIssuer,
updateTarget,
getPolicy,
} from './client';
// Mock global fetch
@@ -1150,4 +1156,53 @@ describe('API Client', () => {
expect(init.headers['Authorization']).toBe('Bearer prom-key');
});
});
describe('Frontend Audit: New API Functions', () => {
it('getCertificateDeployments sends GET with cert ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0 }));
await getCertificateDeployments('mc-1');
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments');
});
it('getCRL sends GET to /crl', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ entries: [], total: 0 }));
await getCRL();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/crl');
});
it('getOCSPStatus sends GET with issuer and serial', async () => {
const buf = new ArrayBuffer(8);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(buf),
} as Response)
);
await getOCSPStatus('iss-local', 'ABC123');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/ocsp/iss-local/ABC123');
});
it('updateIssuer sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-1', name: 'Updated' }));
await updateIssuer('iss-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-1');
expect(init.method).toBe('PUT');
});
it('updateTarget sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-1', name: 'Updated' }));
await updateTarget('t-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-1');
expect(init.method).toBe('PUT');
});
it('getPolicy sends GET with policy ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', name: 'Test' }));
await getPolicy('pol-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1');
});
});
});
+29
View File
@@ -122,6 +122,26 @@ export const exportCertificatePKCS12 = (id: string, password: string = '') => {
});
};
// Certificate Deployments
export const getCertificateDeployments = (id: string, params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/certificates/${id}/deployments?${qs}`);
};
// CRL / OCSP
export const getCRL = () =>
fetchJSON<{ version: number; entries: unknown[]; total: number; generated_at: string }>(`${BASE}/crl`);
export const getOCSPStatus = (issuerId: string, serial: string) => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/ocsp/${issuerId}/${serial}`, { headers })
.then(r => {
if (!r.ok) throw new Error(`OCSP request failed: ${r.status}`);
return r.arrayBuffer();
});
};
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -170,6 +190,9 @@ export const createPolicy = (data: Partial<PolicyRule>) =>
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const getPolicy = (id: string) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`);
export const deletePolicy = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
@@ -188,6 +211,9 @@ export const createIssuer = (data: Partial<Issuer>) =>
export const testIssuerConnection = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
export const updateIssuer = (id: string, data: Partial<Issuer>) =>
fetchJSON<Issuer>(`${BASE}/issuers/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteIssuer = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
@@ -200,6 +226,9 @@ export const getTargets = (params: Record<string, string> = {}) => {
export const createTarget = (data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
export const updateTarget = (id: string, data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteTarget = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
+7
View File
@@ -18,7 +18,10 @@ export interface Certificate {
expires_at: string;
revoked_at?: string;
revocation_reason?: string;
target_ids?: string[];
tags: Record<string, string>;
last_renewal_at?: string;
last_deployment_at?: string;
created_at: string;
updated_at: string;
}
@@ -45,6 +48,8 @@ export interface CertificateVersion {
csr_pem: string;
not_before: string;
not_after: string;
key_algorithm?: string;
key_size?: number;
created_at: string;
}
@@ -138,6 +143,7 @@ export interface Issuer {
/** Backend returns enabled boolean; status is derived from this */
enabled: boolean;
created_at: string;
updated_at?: string;
}
export interface Target {
@@ -149,6 +155,7 @@ export interface Target {
config: Record<string, unknown>;
status: string;
created_at: string;
updated_at?: string;
}
export interface KeyAlgorithmRule {
+1 -1
View File
@@ -71,7 +71,7 @@ export default function Layout() {
</nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.14</span>
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.20</span>
{authRequired && (
<button
onClick={logout}
+10 -2
View File
@@ -13,6 +13,14 @@ const OS_COLORS: Record<string, string> = {
unknown: '#64748b',
};
const OS_DISPLAY_NAMES: Record<string, string> = {
darwin: 'macOS',
};
function displayOS(os: string): string {
return OS_DISPLAY_NAMES[os.toLowerCase()] || os;
}
const STATUS_COLORS: Record<string, string> = {
Online: '#10b981',
Offline: '#ef4444',
@@ -86,7 +94,7 @@ export default function AgentFleetPage() {
return acc;
}, {});
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
name,
name: displayOS(name),
value,
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
}));
@@ -216,7 +224,7 @@ export default function AgentFleetPage() {
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
/>
<h4 className="text-sm font-medium text-ink">
{group.os} / {group.arch}
{displayOS(group.os)} / {group.arch}
</h4>
<span className="text-xs text-ink-faint">
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
+142 -22
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners } from '../api/client';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers } from '../api/client';
import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
@@ -16,20 +16,66 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
name: '',
id: '',
common_name: '',
sans: '',
environment: 'production',
issuer_id: '',
certificate_profile_id: '',
owner_id: '',
team_id: '',
renewal_policy_id: '',
tags: '',
});
const [error, setError] = useState('');
const { data: profilesResp } = useQuery({
queryKey: ['profiles'],
queryFn: () => getProfiles(),
});
const { data: issuersResp } = useQuery({
queryKey: ['issuers'],
queryFn: () => getIssuers(),
});
const profiles = profilesResp?.data || [];
const issuers = issuersResp?.data || [];
const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id);
const ttlLabel = selectedProfile
? selectedProfile.max_ttl_seconds < 3600
? `${Math.round(selectedProfile.max_ttl_seconds / 60)}m`
: selectedProfile.max_ttl_seconds < 86400
? `${Math.round(selectedProfile.max_ttl_seconds / 3600)}h`
: `${Math.round(selectedProfile.max_ttl_seconds / 86400)}d`
: null;
const mutation = useMutation({
mutationFn: () => createCertificate(form),
mutationFn: () => {
const payload: Record<string, unknown> = { ...form };
// Convert comma-separated SANs to array
if (form.sans.trim()) {
payload.sans = form.sans.split(',').map(s => s.trim()).filter(Boolean);
} else {
delete payload.sans;
}
// Convert comma-separated key=value tags to object
if (form.tags.trim()) {
const tags: Record<string, string> = {};
form.tags.split(',').forEach(pair => {
const [k, ...v] = pair.split('=');
if (k?.trim()) tags[k.trim()] = v.join('=').trim();
});
payload.tags = tags;
} else {
delete payload.tags;
}
return createCertificate(payload);
},
onSuccess: () => onSuccess(),
onError: (err: Error) => setError(err.message),
});
const inputClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20";
const selectClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink";
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
@@ -39,57 +85,90 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
<div>
<label className="text-xs text-ink-muted block mb-1">Name *</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, 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 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="API Production Cert" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: 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 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="mc-api-prod (auto-generated if empty)" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Common Name *</label>
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_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 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="api.example.com" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">SANs (comma-separated)</label>
<input value={form.sans} onChange={e => setForm(f => ({ ...f, sans: e.target.value }))}
className={inputClass}
placeholder="api.example.com, api-v2.example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Issuer *</label>
<select value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
className={selectClass}>
<option value="">Select issuer...</option>
{issuers.map(i => (
<option key={i.id} value={i.id}>{i.name}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">
Profile {ttlLabel && <span className="text-brand-400 font-medium">(TTL: {ttlLabel})</span>}
</label>
<select value={form.certificate_profile_id} onChange={e => setForm(f => ({ ...f, certificate_profile_id: e.target.value }))}
className={selectClass}>
<option value="">Select profile...</option>
{profiles.map(p => (
<option key={p.id} value={p.id}>
{p.name}{p.max_ttl_seconds ? ` (${p.max_ttl_seconds < 3600 ? `${Math.round(p.max_ttl_seconds / 60)}m` : p.max_ttl_seconds < 86400 ? `${Math.round(p.max_ttl_seconds / 3600)}h` : `${Math.round(p.max_ttl_seconds / 86400)}d`})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Environment</label>
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink">
className={selectClass}>
<option value="production">Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label>
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: 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 focus:ring-1 focus:ring-brand-400/20"
placeholder="iss-local" />
<label className="text-xs text-ink-muted block mb-1">Policy</label>
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
className={inputClass}
placeholder="rp-standard" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Owner ID</label>
<label className="text-xs text-ink-muted block mb-1">Owner</label>
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: 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 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="o-alice" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Team ID</label>
<label className="text-xs text-ink-muted block mb-1">Team</label>
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: 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 focus:ring-1 focus:ring-brand-400/20"
className={inputClass}
placeholder="t-platform" />
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Policy ID</label>
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: 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 focus:ring-1 focus:ring-brand-400/20"
placeholder="rp-standard" />
</div>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">Tags</label>
<input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
className={inputClass}
placeholder="env=prod, team=platform, app=api" />
<p className="text-xs text-ink-faint mt-0.5">Comma-separated key=value pairs</p>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
@@ -245,15 +324,25 @@ export default function CertificatesPage() {
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState('');
const [envFilter, setEnvFilter] = useState('');
const [issuerFilter, setIssuerFilter] = useState('');
const [ownerFilter, setOwnerFilter] = useState('');
const [profileFilter, setProfileFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
const [showBulkReassign, setShowBulkReassign] = useState(false);
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter;
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['certificates', params],
@@ -302,7 +391,8 @@ export default function CertificatesPage() {
);
},
},
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink-muted">{c.environment || '—'}</span> },
{ key: 'last_renewal', label: 'Last Renewal', render: (c) => <span className="text-xs text-ink-muted">{c.last_renewal_at ? formatDate(c.last_renewal_at) : '—'}</span> },
{ key: 'last_deploy', label: 'Last Deploy', render: (c) => <span className="text-xs text-ink-muted">{c.last_deployment_at ? formatDate(c.last_deployment_at) : '—'}</span> },
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> },
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> },
];
@@ -382,6 +472,36 @@ export default function CertificatesPage() {
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
<select
value={issuerFilter}
onChange={e => setIssuerFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All issuers</option>
{issuersData?.data?.map(i => (
<option key={i.id} value={i.id}>{i.name}</option>
))}
</select>
<select
value={ownerFilter}
onChange={e => setOwnerFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All owners</option>
{ownersData?.data?.map(o => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
<select
value={profileFilter}
onChange={e => setProfileFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All profiles</option>
{profilesData?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div>
<div className="flex-1 overflow-y-auto">
{error ? (
+12
View File
@@ -197,6 +197,18 @@ export default function DiscoveryPage() {
label: 'Expiry',
render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>,
},
{
key: 'key_info',
label: 'Key',
render: (c) => (
<div className="flex items-center gap-1">
<span className="text-xs text-ink-muted">{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}</span>
{c.is_ca && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">CA</span>
)}
</div>
),
},
{
key: 'fingerprint',
label: 'Fingerprint',
+9
View File
@@ -136,6 +136,15 @@ export default function JobsPage() {
label: 'Attempts',
render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>,
},
{
key: 'error',
label: 'Error',
render: (j) => j.status === 'Failed' && j.error_message ? (
<span className="text-xs text-red-600 truncate max-w-[200px] inline-block" title={j.error_message}>
{j.error_message.length > 80 ? j.error_message.substring(0, 80) + '...' : j.error_message}
</span>
) : <span className="text-xs text-ink-faint"></span>,
},
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{
+158 -4
View File
@@ -25,11 +25,63 @@ interface CreateProfileModalProps {
error: string | null;
}
const AVAILABLE_ALGORITHMS = ['RSA', 'ECDSA', 'Ed25519'];
const ALGORITHM_MIN_SIZES: Record<string, number[]> = {
RSA: [2048, 3072, 4096],
ECDSA: [256, 384],
Ed25519: [0],
};
const AVAILABLE_EKUS = [
{ value: 'serverAuth', label: 'Server Authentication (TLS)' },
{ value: 'clientAuth', label: 'Client Authentication' },
{ value: 'codeSigning', label: 'Code Signing' },
{ value: 'emailProtection', label: 'Email Protection (S/MIME)' },
{ value: 'timeStamping', label: 'Time Stamping' },
];
interface KeyAlgorithmEntry {
algorithm: string;
min_size: number;
}
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [ttl, setTtl] = useState('86400');
const [shortLived, setShortLived] = useState(false);
const [keyAlgorithms, setKeyAlgorithms] = useState<KeyAlgorithmEntry[]>([
{ algorithm: 'ECDSA', min_size: 256 },
{ algorithm: 'RSA', min_size: 2048 },
]);
const [selectedEkus, setSelectedEkus] = useState<string[]>(['serverAuth']);
const [sanPatterns, setSanPatterns] = useState('');
const [spiffePattern, setSpiffePattern] = useState('');
const addAlgorithm = () => {
const unused = AVAILABLE_ALGORITHMS.find(a => !keyAlgorithms.some(ka => ka.algorithm === a));
if (unused) {
setKeyAlgorithms([...keyAlgorithms, { algorithm: unused, min_size: ALGORITHM_MIN_SIZES[unused][0] }]);
}
};
const removeAlgorithm = (idx: number) => {
setKeyAlgorithms(keyAlgorithms.filter((_, i) => i !== idx));
};
const updateAlgorithm = (idx: number, field: 'algorithm' | 'min_size', value: string | number) => {
const updated = [...keyAlgorithms];
if (field === 'algorithm') {
updated[idx] = { algorithm: value as string, min_size: ALGORITHM_MIN_SIZES[value as string]?.[0] || 0 };
} else {
updated[idx] = { ...updated[idx], min_size: value as number };
}
setKeyAlgorithms(updated);
};
const toggleEku = (eku: string) => {
setSelectedEkus(prev => prev.includes(eku) ? prev.filter(e => e !== eku) : [...prev, eku]);
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
@@ -39,20 +91,31 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
description: description.trim(),
max_ttl_seconds: parseInt(ttl) || 86400,
allow_short_lived: shortLived,
allowed_key_algorithms: keyAlgorithms,
allowed_ekus: selectedEkus,
required_san_patterns: sanPatterns.trim() ? sanPatterns.split(',').map(s => s.trim()).filter(Boolean) : [],
spiffe_uri_pattern: spiffePattern.trim() || '',
enabled: true,
});
setName('');
setDescription('');
setTtl('86400');
setShortLived(false);
setKeyAlgorithms([{ algorithm: 'ECDSA', min_size: 256 }, { algorithm: 'RSA', min_size: 2048 }]);
setSelectedEkus(['serverAuth']);
setSanPatterns('');
setSpiffePattern('');
onSuccess();
};
if (!isOpen) return null;
const inputClass = 'w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
const selectClass = 'bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Profile</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
@@ -61,7 +124,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
<input
value={name}
onChange={e => setName(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"
className={inputClass}
placeholder="e.g., Web Server Certs"
required
/>
@@ -71,7 +134,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
<textarea
value={description}
onChange={e => setDescription(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"
className={inputClass}
placeholder="Optional description"
rows={2}
/>
@@ -82,7 +145,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
type="number"
value={ttl}
onChange={e => setTtl(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"
className={inputClass}
placeholder="86400"
/>
<p className="text-xs text-ink-muted mt-1">
@@ -109,6 +172,97 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
/>
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
</div>
{/* Allowed Key Algorithms */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-ink">Allowed Key Algorithms</label>
{keyAlgorithms.length < AVAILABLE_ALGORITHMS.length && (
<button type="button" onClick={addAlgorithm} className="text-xs text-brand-600 hover:text-brand-700 font-medium">
+ Add
</button>
)}
</div>
<div className="space-y-2">
{keyAlgorithms.map((ka, idx) => (
<div key={idx} className="flex items-center gap-2">
<select
value={ka.algorithm}
onChange={e => updateAlgorithm(idx, 'algorithm', e.target.value)}
className={selectClass + ' flex-1'}
>
{AVAILABLE_ALGORITHMS.map(a => (
<option key={a} value={a} disabled={a !== ka.algorithm && keyAlgorithms.some(k => k.algorithm === a)}>
{a}
</option>
))}
</select>
{ka.algorithm !== 'Ed25519' ? (
<select
value={ka.min_size}
onChange={e => updateAlgorithm(idx, 'min_size', parseInt(e.target.value))}
className={selectClass + ' w-24'}
>
{(ALGORITHM_MIN_SIZES[ka.algorithm] || []).map(s => (
<option key={s} value={s}>{s}+</option>
))}
</select>
) : (
<span className="text-xs text-ink-muted w-24 text-center">fixed</span>
)}
<button type="button" onClick={() => removeAlgorithm(idx)} className="text-xs text-red-500 hover:text-red-600">
Remove
</button>
</div>
))}
{keyAlgorithms.length === 0 && (
<p className="text-xs text-ink-faint">No algorithms configured. Click + Add to allow key types.</p>
)}
</div>
</div>
{/* Allowed EKUs */}
<div>
<label className="block text-sm font-medium text-ink mb-1">Allowed Extended Key Usages</label>
<div className="space-y-1.5">
{AVAILABLE_EKUS.map(eku => (
<label key={eku.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedEkus.includes(eku.value)}
onChange={() => toggleEku(eku.value)}
className="w-4 h-4"
/>
<span className="text-sm text-ink">{eku.label}</span>
</label>
))}
</div>
</div>
{/* Required SAN Patterns */}
<div>
<label className="block text-sm font-medium text-ink mb-1">Required SAN Patterns</label>
<input
value={sanPatterns}
onChange={e => setSanPatterns(e.target.value)}
className={inputClass}
placeholder="e.g., *.example.com, api.internal"
/>
<p className="text-xs text-ink-muted mt-1">Comma-separated patterns. Leave empty for no constraints.</p>
</div>
{/* SPIFFE URI Pattern */}
<div>
<label className="block text-sm font-medium text-ink mb-1">SPIFFE URI Pattern</label>
<input
value={spiffePattern}
onChange={e => setSpiffePattern(e.target.value)}
className={inputClass}
placeholder="e.g., spiffe://example.org/service/*"
/>
<p className="text-xs text-ink-muted mt-1">Optional workload identity URI SAN pattern.</p>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
+57 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { getTarget, getJobs } from '../api/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTarget, getJobs, updateTarget } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable';
@@ -30,6 +31,18 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editHostname, setEditHostname] = useState('');
const updateMutation = useMutation({
mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['target', id] });
setIsEditing(false);
},
});
const { data: target, isLoading, error, refetch } = useQuery({
queryKey: ['target', id],
@@ -112,6 +125,18 @@ export default function TargetDetailPage() {
<PageHeader
title={target.name}
subtitle={typeLabels[target.type] || target.type}
action={
<button
onClick={() => {
setEditName(target.name);
setEditHostname(target.hostname || '');
setIsEditing(true);
}}
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
>
Edit
</button>
}
/>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
@@ -164,6 +189,36 @@ export default function TargetDetailPage() {
/>
</div>
</div>
{/* Edit Modal */}
{isEditing && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setIsEditing(false)}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Edit Target</h2>
{updateMutation.isError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{(updateMutation.error as Error).message}
</div>
)}
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name</label>
<input value={editName} onChange={e => setEditName(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" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Hostname</label>
<input value={editHostname} onChange={e => setEditHostname(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" />
</div>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
{updateMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button type="button" onClick={() => setIsEditing(false)} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
)}
</>
);
}