mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-13 23:58:53 +00:00
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:
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user