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:
shankar0123
2026-03-16 00:05:21 -04:00
parent 3e9d053026
commit 73c6bd1416
15 changed files with 2034 additions and 29 deletions
+4
View File
@@ -83,6 +83,10 @@ jobs:
working-directory: web
run: npx tsc --noEmit
- name: Run Frontend Tests
working-directory: web
run: npx vitest run
- name: Build Frontend
working-directory: web
run: npx vite build
+1186 -4
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -6,7 +6,9 @@
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"preview": "vite preview"
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@tanstack/react-query": "^5.90.21",
@@ -15,13 +17,17 @@
"react-router-dom": "^6.30.3"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"autoprefixer": "^10.4.27",
"jsdom": "^29.0.0",
"postcss": "^8.5.8",
"tailwindcss": "^3.4.19",
"typescript": "^5.9.3",
"vite": "^8.0.0"
"vite": "^8.0.0",
"vitest": "^4.1.0"
}
}
+381
View File
@@ -0,0 +1,381 @@
import { describe, it, expect, beforeEach, vi } from 'vitest';
import {
setApiKey,
getApiKey,
getCertificates,
getCertificate,
getCertificateVersions,
createCertificate,
triggerRenewal,
triggerDeployment,
updateCertificate,
archiveCertificate,
getAgents,
getAgent,
registerAgent,
getJobs,
cancelJob,
getNotifications,
markNotificationRead,
getAuditEvents,
getPolicies,
updatePolicy,
deletePolicy,
getIssuers,
testIssuerConnection,
deleteIssuer,
getTargets,
createTarget,
deleteTarget,
getHealth,
} from './client';
// Mock global fetch
const mockFetch = vi.fn();
globalThis.fetch = mockFetch;
function mockJsonResponse(data: unknown, status = 200) {
return Promise.resolve({
ok: status >= 200 && status < 300,
status,
json: () => Promise.resolve(data),
statusText: 'OK',
} as Response);
}
function mockErrorResponse(status: number, body: { message?: string; error?: string } = {}) {
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve(body),
statusText: 'Error',
} as Response);
}
describe('API Client', () => {
beforeEach(() => {
mockFetch.mockReset();
setApiKey(null);
});
// ─── Auth ───────────────────────────────────────────
describe('API Key management', () => {
it('stores and retrieves API key', () => {
expect(getApiKey()).toBeNull();
setApiKey('test-key-123');
expect(getApiKey()).toBe('test-key-123');
});
it('clears API key', () => {
setApiKey('test-key');
setApiKey(null);
expect(getApiKey()).toBeNull();
});
});
describe('Auth headers', () => {
it('sends Authorization header when API key is set', async () => {
setApiKey('my-secret-key');
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBe('Bearer my-secret-key');
expect(init.headers['Content-Type']).toBe('application/json');
});
it('omits Authorization header when no API key', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates();
const [, init] = mockFetch.mock.calls[0];
expect(init.headers['Authorization']).toBeUndefined();
expect(init.headers['Content-Type']).toBe('application/json');
});
it('dispatches auth-required event on 401', async () => {
const listener = vi.fn();
window.addEventListener('certctl:auth-required', listener);
mockFetch.mockReturnValueOnce(mockErrorResponse(401));
await expect(getCertificates()).rejects.toThrow('Authentication required');
expect(listener).toHaveBeenCalled();
window.removeEventListener('certctl:auth-required', listener);
});
});
// ─── Error handling ─────────────────────────────────
describe('Error handling', () => {
it('throws with server error message', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(400, { message: 'Invalid request' }));
await expect(getCertificates()).rejects.toThrow('Invalid request');
});
it('throws with error field from response', async () => {
mockFetch.mockReturnValueOnce(mockErrorResponse(500, { error: 'Internal error' }));
await expect(getCertificates()).rejects.toThrow('Internal error');
});
it('falls back to HTTP status text', async () => {
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 503,
json: () => Promise.reject(new Error('not json')),
statusText: 'Service Unavailable',
} as Response),
);
await expect(getCertificates()).rejects.toThrow('Service Unavailable');
});
});
// ─── Certificates ───────────────────────────────────
describe('Certificates', () => {
it('getCertificates sends GET with default pagination', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates();
expect(mockFetch).toHaveBeenCalledWith(
'/api/v1/certificates?page=1&per_page=50',
expect.objectContaining({ headers: expect.any(Object) }),
);
});
it('getCertificates passes filter params', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificates({ status: 'Active', environment: 'production' });
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('status=Active');
expect(url).toContain('environment=production');
});
it('getCertificate fetches single cert by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', common_name: 'test.com' }));
const cert = await getCertificate('mc-test');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/certificates/mc-test');
expect(cert.id).toBe('mc-test');
});
it('getCertificateVersions fetches versions', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getCertificateVersions('mc-test');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/certificates/mc-test/versions');
});
it('createCertificate sends POST with body', async () => {
const certData = { common_name: 'new.example.com', issuer_id: 'iss-local' };
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-new', ...certData }));
await createCertificate(certData);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual(certData);
});
it('updateCertificate sends PUT', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'mc-test', status: 'Active' }));
await updateCertificate('mc-test', { status: 'Active' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test');
expect(init.method).toBe('PUT');
});
it('archiveCertificate sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'archived' }));
await archiveCertificate('mc-test');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test');
expect(init.method).toBe('DELETE');
});
it('triggerRenewal sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'renewal triggered' }));
await triggerRenewal('mc-test');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test/renew');
expect(init.method).toBe('POST');
});
it('triggerDeployment sends POST with target_id', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deployment triggered' }));
await triggerDeployment('mc-test', 't-nginx');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/certificates/mc-test/deploy');
expect(init.method).toBe('POST');
expect(JSON.parse(init.body)).toEqual({ target_id: 't-nginx' });
});
});
// ─── Agents ─────────────────────────────────────────
describe('Agents', () => {
it('getAgents sends GET with pagination', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAgents();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agents?page=1&per_page=50');
});
it('getAgent fetches by ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'a-web01', name: 'web01' }));
const agent = await getAgent('a-web01');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/agents/a-web01');
expect(agent.id).toBe('a-web01');
});
it('registerAgent sends POST', async () => {
const agentData = { name: 'new-agent', hostname: 'host01' };
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'a-new', ...agentData }));
await registerAgent(agentData);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/agents');
expect(init.method).toBe('POST');
});
});
// ─── Jobs ───────────────────────────────────────────
describe('Jobs', () => {
it('getJobs sends GET with filters', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getJobs({ status: 'Pending', type: 'Renewal' });
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('status=Pending');
expect(url).toContain('type=Renewal');
});
it('cancelJob sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'cancelled' }));
await cancelJob('job-123');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/jobs/job-123/cancel');
expect(init.method).toBe('POST');
});
});
// ─── Notifications ──────────────────────────────────
describe('Notifications', () => {
it('getNotifications sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getNotifications();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/notifications');
});
it('markNotificationRead sends POST with auth headers', async () => {
setApiKey('test-key');
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'marked as read' }));
await markNotificationRead('notif-123');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/notifications/notif-123/read');
expect(init.method).toBe('POST');
expect(init.headers['Authorization']).toBe('Bearer test-key');
});
});
// ─── Policies ───────────────────────────────────────
describe('Policies', () => {
it('getPolicies sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getPolicies();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/policies');
});
it('updatePolicy sends PUT with partial data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', enabled: false }));
await updatePolicy('pol-1', { enabled: false });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/policies/pol-1');
expect(init.method).toBe('PUT');
expect(JSON.parse(init.body)).toEqual({ enabled: false });
});
it('deletePolicy sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deletePolicy('pol-1');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/policies/pol-1');
expect(init.method).toBe('DELETE');
});
});
// ─── Issuers ────────────────────────────────────────
describe('Issuers', () => {
it('getIssuers sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getIssuers();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/issuers');
});
it('testIssuerConnection sends POST', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'ok' }));
await testIssuerConnection('iss-local');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-local/test');
expect(init.method).toBe('POST');
});
it('deleteIssuer sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteIssuer('iss-local');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-local');
expect(init.method).toBe('DELETE');
});
});
// ─── Targets ────────────────────────────────────────
describe('Targets', () => {
it('getTargets sends GET', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getTargets();
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/targets');
});
it('createTarget sends POST', async () => {
const targetData = { name: 'nginx-01', type: 'nginx', hostname: 'web01.example.com' };
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-new', ...targetData }));
await createTarget(targetData);
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets');
expect(init.method).toBe('POST');
});
it('deleteTarget sends DELETE', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ message: 'deleted' }));
await deleteTarget('t-nginx');
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-nginx');
expect(init.method).toBe('DELETE');
});
});
// ─── Audit ──────────────────────────────────────────
describe('Audit', () => {
it('getAuditEvents sends GET with filters', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0, page: 1, per_page: 50 }));
await getAuditEvents({ resource_type: 'certificate' });
const url = mockFetch.mock.calls[0][0] as string;
expect(url).toContain('resource_type=certificate');
});
});
// ─── Health ─────────────────────────────────────────
describe('Health', () => {
it('getHealth calls /health endpoint', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'ok' }));
const result = await getHealth();
expect(mockFetch.mock.calls[0][0]).toBe('/health');
expect(result.status).toBe('ok');
});
});
});
+40 -1
View File
@@ -1,4 +1,4 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, Issuer, Target, PaginatedResponse } from './types';
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, PaginatedResponse } from './types';
const BASE = '/api/v1';
@@ -70,6 +70,12 @@ export const createCertificate = (data: Partial<Certificate>) =>
export const triggerRenewal = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
export const updateCertificate = (id: string, data: Partial<Certificate>) =>
fetchJSON<Certificate>(`${BASE}/certificates/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const archiveCertificate = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}`, { method: 'DELETE' });
export const triggerDeployment = (id: string, targetId: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
method: 'POST',
@@ -85,6 +91,9 @@ export const getAgents = (params: Record<string, string> = {}) => {
export const getAgent = (id: string) =>
fetchJSON<Agent>(`${BASE}/agents/${id}`);
export const registerAgent = (data: Partial<Agent>) =>
fetchJSON<Agent>(`${BASE}/agents`, { method: 'POST', body: JSON.stringify(data) });
// Jobs
export const getJobs = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -100,6 +109,9 @@ export const getNotifications = (params: Record<string, string> = {}) => {
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
};
export const markNotificationRead = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/notifications/${id}/read`, { method: 'POST' });
// Audit
export const getAuditEvents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -112,17 +124,44 @@ export const getPolicies = (params: Record<string, string> = {}) => {
return fetchJSON<PaginatedResponse<PolicyRule>>(`${BASE}/policies?${qs}`);
};
export const createPolicy = (data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies`, { method: 'POST', body: JSON.stringify(data) });
export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deletePolicy = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
export const getPolicyViolations = (id: string) =>
fetchJSON<PaginatedResponse<PolicyViolation>>(`${BASE}/policies/${id}/violations`);
// Issuers
export const getIssuers = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Issuer>>(`${BASE}/issuers?${qs}`);
};
export const createIssuer = (data: Partial<Issuer>) =>
fetchJSON<Issuer>(`${BASE}/issuers`, { method: 'POST', body: JSON.stringify(data) });
export const testIssuerConnection = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
export const deleteIssuer = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
// Targets
export const getTargets = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Target>>(`${BASE}/targets?${qs}`);
};
export const createTarget = (data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
export const deleteTarget = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');
+110
View File
@@ -0,0 +1,110 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { formatDate, formatDateTime, timeAgo, daysUntil, expiryColor } from './utils';
describe('Utility functions', () => {
describe('formatDate', () => {
it('returns dash for empty string', () => {
expect(formatDate('')).toBe('—');
});
it('formats ISO date string', () => {
const result = formatDate('2026-06-15T12:00:00Z');
expect(result).toContain('Jun');
expect(result).toContain('15');
expect(result).toContain('2026');
});
});
describe('formatDateTime', () => {
it('returns dash for empty string', () => {
expect(formatDateTime('')).toBe('—');
});
it('formats ISO datetime string with time', () => {
const result = formatDateTime('2026-06-15T14:30:00Z');
expect(result).toContain('Jun');
expect(result).toContain('15');
});
});
describe('timeAgo', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns dash for empty string', () => {
expect(timeAgo('')).toBe('—');
});
it('returns "just now" for recent times', () => {
expect(timeAgo('2026-03-15T11:59:45Z')).toBe('just now');
});
it('returns minutes ago', () => {
expect(timeAgo('2026-03-15T11:45:00Z')).toBe('15m ago');
});
it('returns hours ago', () => {
expect(timeAgo('2026-03-15T09:00:00Z')).toBe('3h ago');
});
it('returns days ago', () => {
expect(timeAgo('2026-03-12T12:00:00Z')).toBe('3d ago');
});
it('returns formatted date for old dates', () => {
const result = timeAgo('2025-01-15T12:00:00Z');
expect(result).toContain('2025');
});
});
describe('daysUntil', () => {
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-03-15T12:00:00Z'));
});
afterEach(() => {
vi.useRealTimers();
});
it('returns 0 for empty string', () => {
expect(daysUntil('')).toBe(0);
});
it('returns positive days for future date', () => {
expect(daysUntil('2026-03-25T12:00:00Z')).toBe(10);
});
it('returns negative days for past date', () => {
expect(daysUntil('2026-03-10T12:00:00Z')).toBeLessThan(0);
});
});
describe('expiryColor', () => {
it('returns red for expired (0 days)', () => {
expect(expiryColor(0)).toContain('red');
});
it('returns red for <= 7 days', () => {
expect(expiryColor(5)).toContain('red');
});
it('returns amber for <= 14 days', () => {
expect(expiryColor(12)).toContain('amber');
});
it('returns amber for <= 30 days', () => {
expect(expiryColor(25)).toContain('amber');
});
it('returns green for > 30 days', () => {
expect(expiryColor(60)).toContain('emerald');
});
});
});
+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 (
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom';
+2 -1
View File
@@ -15,7 +15,8 @@
"noUnusedLocals": false,
"noUnusedParameters": false,
"noFallthroughCasesInSwitch": true,
"forceConsistentCasingInFileNames": true
"forceConsistentCasingInFileNames": true,
"types": ["vitest/globals"]
},
"include": ["src"]
}
+6 -1
View File
@@ -13,5 +13,10 @@ export default defineConfig({
build: {
outDir: 'dist',
sourcemap: false,
}
},
test: {
globals: true,
environment: 'jsdom',
setupFiles: ['./src/test/setup.ts'],
},
})