Implement M5: hardening, input validation, and Vite+React+TS dashboard

Backend hardening:
- Fix 6 nginx.go non-constant format string build errors
- Add validation.go with hostname, PEM, and enum validators
- Apply input validation to all POST/PUT handlers (certificates,
  agents, CSR, policies, teams, owners, targets, issuers)
- Fix unchecked JSON decode in TriggerDeployment handler

Frontend (Vite + React + TypeScript):
- Migrate from single-file SPA to proper build pipeline
- 7 pages: Dashboard, Certificates (list+detail), Agents, Jobs,
  Notifications, Policies, Audit Trail
- TanStack Query for server state with auto-refetch intervals
- Certificate detail with version history and renewal trigger
- Job cancellation, status/type filtering, expiry countdowns
- Reusable components: DataTable, StatusBadge, ErrorState, PageHeader
- Dark theme with Tailwind CSS, sidebar nav via React Router

Server integration:
- Go server serves web/dist/ (Vite output) with SPA fallback
- Falls back to web/index.html for legacy mode
- .gitignore updated for web/node_modules/ and web/dist/

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-15 01:19:19 -04:00
parent 7845d282e9
commit 9e6756d02f
39 changed files with 5725 additions and 1878 deletions
+90
View File
@@ -0,0 +1,90 @@
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, Issuer, Target, PaginatedResponse } from './types';
const BASE = '/api/v1';
async function fetchJSON<T>(url: string, init?: RequestInit): Promise<T> {
const res = await fetch(url, {
headers: { 'Content-Type': 'application/json', ...init?.headers },
...init,
});
if (!res.ok) {
const body = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(body.message || body.error || `HTTP ${res.status}`);
}
return res.json();
}
// Certificates
export const getCertificates = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Certificate>>(`${BASE}/certificates?${qs}`);
};
export const getCertificate = (id: string) =>
fetchJSON<Certificate>(`${BASE}/certificates/${id}`);
export const getCertificateVersions = (id: string) =>
fetchJSON<PaginatedResponse<CertificateVersion>>(`${BASE}/certificates/${id}/versions`);
export const createCertificate = (data: Partial<Certificate>) =>
fetchJSON<Certificate>(`${BASE}/certificates`, { method: 'POST', body: JSON.stringify(data) });
export const triggerRenewal = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/renew`, { method: 'POST' });
export const triggerDeployment = (id: string, targetId: string) =>
fetchJSON<{ message: string }>(`${BASE}/certificates/${id}/deploy`, {
method: 'POST',
body: JSON.stringify({ target_id: targetId }),
});
// Agents
export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Agent>>(`${BASE}/agents?${qs}`);
};
export const getAgent = (id: string) =>
fetchJSON<Agent>(`${BASE}/agents/${id}`);
// Jobs
export const getJobs = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/jobs?${qs}`);
};
export const cancelJob = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/jobs/${id}/cancel`, { method: 'POST' });
// Notifications
export const getNotifications = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Notification>>(`${BASE}/notifications?${qs}`);
};
// Audit
export const getAuditEvents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<AuditEvent>>(`${BASE}/audit?${qs}`);
};
// Policies
export const getPolicies = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<PolicyRule>>(`${BASE}/policies?${qs}`);
};
// 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}`);
};
// 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}`);
};
// Health
export const getHealth = () => fetchJSON<{ status: string }>('/health');
+133
View File
@@ -0,0 +1,133 @@
export interface Certificate {
id: string;
name: string;
common_name: string;
sans: string[];
status: string;
environment: string;
issuer_id: string;
owner_id: string;
team_id: string;
renewal_policy_id: string;
serial_number: string;
fingerprint: string;
key_algorithm: string;
key_size: number;
issued_at: string;
expires_at: string;
tags: Record<string, string>;
created_at: string;
updated_at: string;
}
export interface CertificateVersion {
id: string;
certificate_id: string;
version: number;
serial_number: string;
fingerprint: string;
cert_pem: string;
chain_pem: string;
csr_pem: string;
not_before: string;
not_after: string;
created_at: string;
}
export interface Agent {
id: string;
name: string;
hostname: string;
ip_address: string;
status: string;
version: string;
last_heartbeat: string;
capabilities: string[];
tags: Record<string, string>;
created_at: string;
updated_at: string;
}
export interface Job {
id: string;
certificate_id: string;
type: string;
status: string;
attempts: number;
max_attempts: number;
error_message: string;
scheduled_at: string;
started_at: string;
completed_at: string;
created_at: string;
}
export interface Notification {
id: string;
type: string;
channel: string;
recipient: string;
subject: string;
message: string;
status: string;
certificate_id: string;
created_at: string;
}
export interface AuditEvent {
id: string;
actor: string;
actor_type: string;
action: string;
resource_type: string;
resource_id: string;
details: Record<string, unknown>;
timestamp: string;
}
export interface PolicyRule {
id: string;
name: string;
type: string;
severity: string;
config: Record<string, unknown>;
enabled: boolean;
created_at: string;
updated_at: string;
}
export interface PolicyViolation {
id: string;
rule_id: string;
certificate_id: string;
severity: string;
message: string;
created_at: string;
}
export interface Issuer {
id: string;
name: string;
type: string;
config: Record<string, unknown>;
status: string;
created_at: string;
}
export interface Target {
id: string;
name: string;
type: string;
hostname: string;
agent_id: string;
config: Record<string, unknown>;
status: string;
created_at: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
per_page: number;
}
+37
View File
@@ -0,0 +1,37 @@
export function formatDate(iso: string): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
}
export function formatDateTime(iso: string): string {
if (!iso) return '—';
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
}
export function timeAgo(iso: string): string {
if (!iso) return '—';
const now = Date.now();
const then = new Date(iso).getTime();
const diff = now - then;
const mins = Math.floor(diff / 60000);
if (mins < 1) return 'just now';
if (mins < 60) return `${mins}m ago`;
const hours = Math.floor(mins / 60);
if (hours < 24) return `${hours}h ago`;
const days = Math.floor(hours / 24);
if (days < 30) return `${days}d ago`;
return formatDate(iso);
}
export function daysUntil(iso: string): number {
if (!iso) return 0;
return Math.ceil((new Date(iso).getTime() - Date.now()) / 86400000);
}
export function expiryColor(days: number): string {
if (days <= 0) return 'text-red-400';
if (days <= 7) return 'text-red-400';
if (days <= 14) return 'text-amber-400';
if (days <= 30) return 'text-amber-300';
return 'text-emerald-400';
}