mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 11:58:52 +00:00
feat: M11b — ownership tracking, agent groups, interactive renewal approval
Ownership: owners/teams GUI pages, notification email resolution via resolveRecipient (owner_id → owner.email lookup). Agent groups: dynamic device grouping by OS/arch/IP CIDR/version with manual include/exclude membership, migration 000004, full CRUD stack (domain → repo → service → handler → frontend). Interactive approval: AwaitingApproval job state, approve/reject API endpoints with reason tracking. Tests: 12 agent group handler tests, 8 approve/reject job handler tests, integration tests updated for 13-param RegisterHandlers. Docs updated across architecture, concepts, and seed data. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+65
-1
@@ -1,4 +1,4 @@
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, PaginatedResponse } from './types';
|
||||
import type { Certificate, CertificateVersion, Agent, Job, Notification, AuditEvent, PolicyRule, PolicyViolation, Issuer, Target, CertificateProfile, Owner, Team, AgentGroup, PaginatedResponse } from './types';
|
||||
|
||||
const BASE = '/api/v1';
|
||||
|
||||
@@ -187,5 +187,69 @@ export const updateProfile = (id: string, data: Partial<CertificateProfile>) =>
|
||||
export const deleteProfile = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/profiles/${id}`, { method: 'DELETE' });
|
||||
|
||||
// Owners
|
||||
export const getOwners = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<Owner>>(`${BASE}/owners?${qs}`);
|
||||
};
|
||||
|
||||
export const getOwner = (id: string) =>
|
||||
fetchJSON<Owner>(`${BASE}/owners/${id}`);
|
||||
|
||||
export const createOwner = (data: Partial<Owner>) =>
|
||||
fetchJSON<Owner>(`${BASE}/owners`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateOwner = (id: string, data: Partial<Owner>) =>
|
||||
fetchJSON<Owner>(`${BASE}/owners/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteOwner = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/owners/${id}`, { method: 'DELETE' });
|
||||
|
||||
// Teams
|
||||
export const getTeams = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<Team>>(`${BASE}/teams?${qs}`);
|
||||
};
|
||||
|
||||
export const getTeam = (id: string) =>
|
||||
fetchJSON<Team>(`${BASE}/teams/${id}`);
|
||||
|
||||
export const createTeam = (data: Partial<Team>) =>
|
||||
fetchJSON<Team>(`${BASE}/teams`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateTeam = (id: string, data: Partial<Team>) =>
|
||||
fetchJSON<Team>(`${BASE}/teams/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteTeam = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/teams/${id}`, { method: 'DELETE' });
|
||||
|
||||
// Agent Groups
|
||||
export const getAgentGroups = (params: Record<string, string> = {}) => {
|
||||
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
|
||||
return fetchJSON<PaginatedResponse<AgentGroup>>(`${BASE}/agent-groups?${qs}`);
|
||||
};
|
||||
|
||||
export const getAgentGroup = (id: string) =>
|
||||
fetchJSON<AgentGroup>(`${BASE}/agent-groups/${id}`);
|
||||
|
||||
export const createAgentGroup = (data: Partial<AgentGroup>) =>
|
||||
fetchJSON<AgentGroup>(`${BASE}/agent-groups`, { method: 'POST', body: JSON.stringify(data) });
|
||||
|
||||
export const updateAgentGroup = (id: string, data: Partial<AgentGroup>) =>
|
||||
fetchJSON<AgentGroup>(`${BASE}/agent-groups/${id}`, { method: 'PUT', body: JSON.stringify(data) });
|
||||
|
||||
export const deleteAgentGroup = (id: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/agent-groups/${id}`, { method: 'DELETE' });
|
||||
|
||||
export const getAgentGroupMembers = (id: string) =>
|
||||
fetchJSON<PaginatedResponse<Agent>>(`${BASE}/agent-groups/${id}/members`);
|
||||
|
||||
// Renewal Approvals
|
||||
export const approveRenewal = (jobId: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/approve`, { method: 'POST' });
|
||||
|
||||
export const rejectRenewal = (jobId: string, reason: string) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/jobs/${jobId}/reject`, { method: 'POST', body: JSON.stringify({ reason }) });
|
||||
|
||||
// Health
|
||||
export const getHealth = () => fetchJSON<{ status: string }>('/health');
|
||||
|
||||
@@ -149,6 +149,43 @@ export interface CertificateProfile {
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Owner {
|
||||
id: string;
|
||||
name: string;
|
||||
email: string;
|
||||
team_id: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface Team {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AgentGroup {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
match_os: string;
|
||||
match_architecture: string;
|
||||
match_ip_cidr: string;
|
||||
match_version: string;
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AgentGroupMembership {
|
||||
agent_group_id: string;
|
||||
agent_id: string;
|
||||
membership_type: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
|
||||
@@ -11,6 +11,9 @@ const nav = [
|
||||
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
||||
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
||||
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
||||
{ to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
|
||||
{ to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
|
||||
{ to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' },
|
||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
||||
];
|
||||
|
||||
|
||||
@@ -17,6 +17,9 @@ import PoliciesPage from './pages/PoliciesPage';
|
||||
import IssuersPage from './pages/IssuersPage';
|
||||
import TargetsPage from './pages/TargetsPage';
|
||||
import ProfilesPage from './pages/ProfilesPage';
|
||||
import OwnersPage from './pages/OwnersPage';
|
||||
import TeamsPage from './pages/TeamsPage';
|
||||
import AgentGroupsPage from './pages/AgentGroupsPage';
|
||||
import AuditPage from './pages/AuditPage';
|
||||
import './index.css';
|
||||
|
||||
@@ -50,6 +53,9 @@ createRoot(document.getElementById('root')!).render(
|
||||
<Route path="profiles" element={<ProfilesPage />} />
|
||||
<Route path="issuers" element={<IssuersPage />} />
|
||||
<Route path="targets" element={<TargetsPage />} />
|
||||
<Route path="owners" element={<OwnersPage />} />
|
||||
<Route path="teams" element={<TeamsPage />} />
|
||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
||||
<Route path="audit" element={<AuditPage />} />
|
||||
</Route>
|
||||
</Routes>
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getAgentGroups, deleteAgentGroup } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { AgentGroup } from '../api/types';
|
||||
|
||||
export default function AgentGroupsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['agent-groups'],
|
||||
queryFn: () => getAgentGroups(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteAgentGroup,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-groups'] }),
|
||||
});
|
||||
|
||||
const columns: Column<AgentGroup>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Group',
|
||||
render: (g) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{g.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{g.id}</div>
|
||||
{g.description && (
|
||||
<div className="text-xs text-slate-400 mt-0.5 max-w-xs truncate">{g.description}</div>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'criteria',
|
||||
label: 'Match Criteria',
|
||||
render: (g) => {
|
||||
const criteria: string[] = [];
|
||||
if (g.match_os) criteria.push(`OS: ${g.match_os}`);
|
||||
if (g.match_architecture) criteria.push(`Arch: ${g.match_architecture}`);
|
||||
if (g.match_ip_cidr) criteria.push(`IP: ${g.match_ip_cidr}`);
|
||||
if (g.match_version) criteria.push(`Ver: ${g.match_version}`);
|
||||
return criteria.length > 0 ? (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{criteria.map((c, i) => (
|
||||
<span key={i} className="badge badge-neutral text-xs">{c}</span>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-slate-500 text-xs">Manual only</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Status',
|
||||
render: (g) => <StatusBadge status={g.enabled ? 'active' : 'disabled'} />,
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: 'Created',
|
||||
render: (g) => <span className="text-xs text-slate-400">{formatDateTime(g.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (g) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Agent Groups" subtitle={data ? `${data.total} groups` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agent groups configured" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getOwners, getTeams, deleteOwner } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Owner, Team } from '../api/types';
|
||||
|
||||
export default function OwnersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['owners'],
|
||||
queryFn: () => getOwners(),
|
||||
});
|
||||
|
||||
const { data: teamsData } = useQuery({
|
||||
queryKey: ['teams'],
|
||||
queryFn: () => getTeams(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteOwner,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['owners'] }),
|
||||
});
|
||||
|
||||
const teamMap = new Map<string, Team>();
|
||||
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
|
||||
|
||||
const columns: Column<Owner>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Owner',
|
||||
render: (o) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{o.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{o.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'Email',
|
||||
render: (o) => <span className="text-slate-300">{o.email || '\u2014'}</span>,
|
||||
},
|
||||
{
|
||||
key: 'team',
|
||||
label: 'Team',
|
||||
render: (o) => {
|
||||
const team = teamMap.get(o.team_id);
|
||||
return team
|
||||
? <span className="text-blue-400">{team.name}</span>
|
||||
: <span className="text-slate-500 font-mono text-xs">{o.team_id || '\u2014'}</span>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
label: 'Created',
|
||||
render: (o) => <span className="text-xs text-slate-400">{formatDateTime(o.created_at)}</span>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
label: '',
|
||||
render: (o) => (
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Owners" subtitle={data ? `${data.total} owners` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No owners configured" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTeams, deleteTeam } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Team } from '../api/types';
|
||||
|
||||
export default function TeamsPage() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['teams'],
|
||||
queryFn: () => getTeams(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: deleteTeam,
|
||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['teams'] }),
|
||||
});
|
||||
|
||||
const columns: Column<Team>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
label: 'Team',
|
||||
render: (t) => (
|
||||
<div>
|
||||
<div className="font-medium text-slate-200">{t.name}</div>
|
||||
<div className="text-xs text-slate-500 font-mono">{t.id}</div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Description',
|
||||
render: (t) => (
|
||||
<span className="text-slate-300 text-sm max-w-sm truncate block">{t.description || '\u2014'}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
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 team ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
||||
className="text-xs text-red-400 hover:text-red-300 transition-colors"
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageHeader title="Teams" subtitle={data ? `${data.total} teams` : undefined} />
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No teams configured" />
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user