feat: add frontend action buttons, fix notification auth bug, add 53 Vitest tests

Bug fix:
- markNotificationRead was using raw fetch() without auth headers,
  bypassing the shared client's Authorization header. Moved to
  api/client.ts to use fetchJSON with proper auth.

New action buttons:
- CertificatesPage: "New Certificate" modal with form fields
- CertificateDetailPage: "Deploy" button with target selector modal,
  "Archive" button with confirmation
- IssuersPage: "Test Connection" and "Delete" per-row actions
- TargetsPage: "Delete" per-row action
- PoliciesPage: "Enable/Disable" toggle and "Delete" per-row actions

New API client functions:
- updateCertificate, archiveCertificate, registerAgent,
  createPolicy, updatePolicy, deletePolicy, getPolicyViolations,
  createIssuer, testIssuerConnection, deleteIssuer,
  createTarget, deleteTarget, markNotificationRead

Frontend tests (53 tests, 2 files):
- client.test.ts: 35 tests covering all API endpoints, auth headers,
  401 handling, error parsing, HTTP methods, request bodies
- utils.test.ts: 18 tests covering formatDate, formatDateTime,
  timeAgo, daysUntil, expiryColor

CI: Added "Run Frontend Tests" step to frontend-build job

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Shankar
2026-03-16 00:05:21 -04:00
parent 86580deab5
commit ff10c85c68
15 changed files with 2034 additions and 29 deletions
+107 -2
View File
@@ -1,7 +1,7 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom';
import { getCertificates } from '../api/client';
import { getCertificates, createCertificate } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -10,10 +10,101 @@ import ErrorState from '../components/ErrorState';
import { formatDate, daysUntil, expiryColor } from '../api/utils';
import type { Certificate } from '../api/types';
function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; onSuccess: () => void }) {
const [form, setForm] = useState({
id: '',
common_name: '',
environment: 'production',
issuer_id: '',
owner_id: '',
team_id: '',
renewal_policy_id: '',
});
const [error, setError] = useState('');
const mutation = useMutation({
mutationFn: () => createCertificate(form),
onSuccess: () => onSuccess(),
onError: (err: Error) => setError(err.message),
});
return (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-lg shadow-2xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-slate-200 mb-4">New Certificate</h2>
{error && <div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-4">{error}</div>}
<div className="space-y-3">
<div>
<label className="text-xs text-slate-400 block mb-1">ID (optional)</label>
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
placeholder="mc-api-prod (auto-generated if empty)" />
</div>
<div>
<label className="text-xs text-slate-400 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-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
placeholder="api.example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Environment</label>
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200">
<option value="production">Production</option>
<option value="staging">Staging</option>
<option value="development">Development</option>
</select>
</div>
<div>
<label className="text-xs text-slate-400 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-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
placeholder="iss-local" />
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div>
<label className="text-xs text-slate-400 block mb-1">Owner ID</label>
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
placeholder="o-alice" />
</div>
<div>
<label className="text-xs text-slate-400 block mb-1">Team ID</label>
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
placeholder="t-platform" />
</div>
<div>
<label className="text-xs text-slate-400 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-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 focus:outline-none focus:border-blue-500"
placeholder="rp-standard" />
</div>
</div>
</div>
<div className="flex justify-end gap-3 mt-6">
<button onClick={onClose} className="btn btn-ghost text-sm">Cancel</button>
<button
onClick={() => mutation.mutate()}
disabled={!form.common_name || !form.issuer_id || mutation.isPending}
className="btn btn-primary text-sm disabled:opacity-50"
>
{mutation.isPending ? 'Creating...' : 'Create Certificate'}
</button>
</div>
</div>
</div>
);
}
export default function CertificatesPage() {
const navigate = useNavigate();
const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState('');
const [envFilter, setEnvFilter] = useState('');
const [showCreate, setShowCreate] = useState(false);
const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter;
@@ -60,6 +151,11 @@ export default function CertificatesPage() {
<PageHeader
title="Certificates"
subtitle={data ? `${data.total} certificates` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary text-xs">
+ New Certificate
</button>
}
/>
<div className="px-6 py-3 flex gap-3 border-b border-slate-700/50">
<select
@@ -98,6 +194,15 @@ export default function CertificatesPage() {
/>
)}
</div>
{showCreate && (
<CreateCertificateModal
onClose={() => setShowCreate(false)}
onSuccess={() => {
setShowCreate(false);
queryClient.invalidateQueries({ queryKey: ['certificates'] });
}}
/>
)}
</>
);
}