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
+91 -1
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getCertificate, getCertificateVersions, triggerRenewal } from '../api/client';
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, getTargets } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
@@ -19,6 +20,8 @@ export default function CertificateDetailPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showDeploy, setShowDeploy] = useState(false);
const [deployTargetId, setDeployTargetId] = useState('');
const { data: cert, isLoading, error, refetch } = useQuery({
queryKey: ['certificate', id],
@@ -32,6 +35,12 @@ export default function CertificateDetailPage() {
enabled: !!id,
});
const { data: targets } = useQuery({
queryKey: ['targets'],
queryFn: () => getTargets(),
enabled: showDeploy,
});
const renewMutation = useMutation({
mutationFn: () => triggerRenewal(id!),
onSuccess: () => {
@@ -40,6 +49,23 @@ export default function CertificateDetailPage() {
},
});
const deployMutation = useMutation({
mutationFn: () => triggerDeployment(id!, deployTargetId),
onSuccess: () => {
setShowDeploy(false);
setDeployTargetId('');
queryClient.invalidateQueries({ queryKey: ['certificate', id] });
},
});
const archiveMutation = useMutation({
mutationFn: () => archiveCertificate(id!),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['certificates'] });
navigate('/certificates');
},
});
if (isLoading) {
return (
<>
@@ -70,6 +96,13 @@ export default function CertificateDetailPage() {
<button onClick={() => navigate('/certificates')} className="btn btn-ghost text-xs">
Back
</button>
<button
onClick={() => setShowDeploy(true)}
disabled={cert.status === 'Archived'}
className="btn btn-ghost text-xs border border-slate-600 disabled:opacity-50"
>
Deploy
</button>
<button
onClick={() => renewMutation.mutate()}
disabled={renewMutation.isPending || cert.status === 'Archived' || cert.status === 'RenewalInProgress'}
@@ -77,6 +110,15 @@ export default function CertificateDetailPage() {
>
{renewMutation.isPending ? 'Renewing...' : 'Trigger Renewal'}
</button>
{cert.status !== 'Archived' && (
<button
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
disabled={archiveMutation.isPending}
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
>
{archiveMutation.isPending ? 'Archiving...' : 'Archive'}
</button>
)}
</div>
}
/>
@@ -91,6 +133,21 @@ export default function CertificateDetailPage() {
Failed to trigger renewal: {(renewMutation.error as Error).message}
</div>
)}
{deployMutation.isSuccess && (
<div className="bg-emerald-500/10 border border-emerald-500/20 text-emerald-400 rounded-lg px-4 py-3 text-sm">
Deployment triggered. A deployment job has been created.
</div>
)}
{deployMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
Failed to deploy: {(deployMutation.error as Error).message}
</div>
)}
{archiveMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-4 py-3 text-sm">
Failed to archive: {(archiveMutation.error as Error).message}
</div>
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* Certificate Info */}
@@ -163,6 +220,39 @@ export default function CertificateDetailPage() {
)}
</div>
</div>
{showDeploy && (
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50" onClick={() => setShowDeploy(false)}>
<div className="bg-slate-800 border border-slate-600 rounded-xl p-6 w-full max-w-md shadow-2xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-slate-200 mb-4">Deploy Certificate</h2>
{deployMutation.isError && (
<div className="bg-red-500/10 border border-red-500/20 text-red-400 rounded-lg px-3 py-2 text-sm mb-3">
{(deployMutation.error as Error).message}
</div>
)}
<label className="text-xs text-slate-400 block mb-2">Select Target</label>
<select
value={deployTargetId}
onChange={e => setDeployTargetId(e.target.value)}
className="w-full bg-slate-900 border border-slate-600 rounded-lg px-3 py-2 text-sm text-slate-200 mb-4"
>
<option value="">Choose a target...</option>
{targets?.data?.map(t => (
<option key={t.id} value={t.id}>{t.name} ({t.type} {t.hostname})</option>
))}
</select>
<div className="flex justify-end gap-3">
<button onClick={() => setShowDeploy(false)} className="btn btn-ghost text-sm">Cancel</button>
<button
onClick={() => deployMutation.mutate()}
disabled={!deployTargetId || deployMutation.isPending}
className="btn btn-primary text-sm disabled:opacity-50"
>
{deployMutation.isPending ? 'Deploying...' : 'Deploy'}
</button>
</div>
</div>
</div>
)}
</>
);
}
+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'] });
}}
/>
)}
</>
);
}
+44 -2
View File
@@ -1,5 +1,6 @@
import { useQuery } from '@tanstack/react-query';
import { getIssuers } from '../api/client';
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -16,11 +17,25 @@ const typeLabels: Record<string, string> = {
};
export default function IssuersPage() {
const queryClient = useQueryClient();
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['issuers'],
queryFn: () => getIssuers(),
});
const testMutation = useMutation({
mutationFn: testIssuerConnection,
onSuccess: (_data, id) => setTestResult({ id, ok: true, msg: 'Connection successful' }),
onError: (err: Error, id) => setTestResult({ id, ok: false, msg: err.message }),
});
const deleteMutation = useMutation({
mutationFn: deleteIssuer,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['issuers'] }),
});
const columns: Column<Issuer>[] = [
{
key: 'name',
@@ -61,11 +76,38 @@ export default function IssuersPage() {
label: 'Created',
render: (i) => <span className="text-xs text-slate-400">{formatDateTime(i.created_at)}</span>,
},
{
key: 'actions',
label: '',
render: (i) => (
<div className="flex gap-2">
<button
onClick={(e) => { e.stopPropagation(); testMutation.mutate(i.id); }}
disabled={testMutation.isPending}
className="text-xs text-blue-400 hover:text-blue-300 transition-colors"
>
Test
</button>
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete issuer ${i.name}?`)) deleteMutation.mutate(i.id); }}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Delete
</button>
</div>
),
},
];
return (
<>
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : undefined} />
{testResult && (
<div className={`mx-6 mt-3 rounded-lg px-4 py-3 text-sm ${testResult.ok ? 'bg-emerald-500/10 border border-emerald-500/20 text-emerald-400' : 'bg-red-500/10 border border-red-500/20 text-red-400'}`}>
{testResult.id}: {testResult.msg}
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
</div>
)}
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
+1 -8
View File
@@ -1,19 +1,12 @@
import { useState, useMemo } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getNotifications } from '../api/client';
import { getNotifications, markNotificationRead } from '../api/client';
import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState';
import { formatDateTime, timeAgo } from '../api/utils';
import type { Notification } from '../api/types';
const BASE = '/api/v1';
async function markNotificationRead(id: string) {
const res = await fetch(`${BASE}/notifications/${id}/read`, { method: 'POST' });
if (!res.ok) throw new Error('Failed to mark as read');
}
type ViewMode = 'list' | 'grouped';
export default function NotificationsPage() {
+32 -5
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { getPolicies } from '../api/client';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPolicies, updatePolicy, deletePolicy } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -22,11 +22,23 @@ const severityDots: Record<string, string> = {
};
export default function PoliciesPage() {
const queryClient = useQueryClient();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['policies'],
queryFn: () => getPolicies(),
});
const toggleMutation = useMutation({
mutationFn: ({ id, enabled }: { id: string; enabled: boolean }) => updatePolicy(id, { enabled }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
});
const deleteMutation = useMutation({
mutationFn: deletePolicy,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
});
const policies = data?.data || [];
const enabledCount = policies.filter(p => p.enabled).length;
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
@@ -67,12 +79,27 @@ export default function PoliciesPage() {
key: 'enabled',
label: 'Enabled',
render: (p) => (
<span className={p.enabled ? 'text-emerald-400' : 'text-slate-500'}>
{p.enabled ? 'Yes' : 'No'}
</span>
<button
onClick={(e) => { e.stopPropagation(); toggleMutation.mutate({ id: p.id, enabled: !p.enabled }); }}
className={`text-xs font-medium transition-colors ${p.enabled ? 'text-emerald-400 hover:text-emerald-300' : 'text-slate-500 hover:text-slate-300'}`}
>
{p.enabled ? 'Enabled' : 'Disabled'}
</button>
),
},
{ key: 'created', label: 'Created', render: (p) => <span className="text-xs text-slate-400">{formatDateTime(p.created_at)}</span> },
{
key: 'actions',
label: '',
render: (p) => (
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete policy ${p.name}?`)) deleteMutation.mutate(p.id); }}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Delete
</button>
),
},
];
return (
+21 -2
View File
@@ -1,5 +1,5 @@
import { useQuery } from '@tanstack/react-query';
import { getTargets } from '../api/client';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getTargets, deleteTarget } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -17,11 +17,18 @@ const typeLabels: Record<string, string> = {
};
export default function TargetsPage() {
const queryClient = useQueryClient();
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['targets'],
queryFn: () => getTargets(),
});
const deleteMutation = useMutation({
mutationFn: deleteTarget,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['targets'] }),
});
const columns: Column<Target>[] = [
{
key: 'name',
@@ -60,6 +67,18 @@ export default function TargetsPage() {
label: 'Created',
render: (t) => <span className="text-xs text-slate-400">{formatDateTime(t.created_at)}</span>,
},
{
key: 'actions',
label: '',
render: (t) => (
<button
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
className="text-xs text-red-400 hover:text-red-300 transition-colors"
>
Delete
</button>
),
},
];
return (