mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 16:11:29 +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:
@@ -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
|
||||
|
||||
Generated
+1186
-4
File diff suppressed because it is too large
Load Diff
+8
-2
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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');
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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'] });
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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() {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
import '@testing-library/jest-dom';
|
||||
+2
-1
@@ -15,7 +15,8 @@
|
||||
"noUnusedLocals": false,
|
||||
"noUnusedParameters": false,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"forceConsistentCasingInFileNames": true
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"types": ["vitest/globals"]
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
|
||||
+6
-1
@@ -13,5 +13,10 @@ export default defineConfig({
|
||||
build: {
|
||||
outDir: 'dist',
|
||||
sourcemap: false,
|
||||
}
|
||||
},
|
||||
test: {
|
||||
globals: true,
|
||||
environment: 'jsdom',
|
||||
setupFiles: ['./src/test/setup.ts'],
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user