feat(gui): add create modals for issuers, policies, profiles, owners, teams, agent groups

Six pages were read-only viewers despite the API client having all
create functions wired up. Users deploying certctl had no way to create
CAs or other objects from the GUI — reported in GitHub issue.

- IssuersPage: 2-step create modal (type selection → config) for
  Local CA, ACME, step-ca, OpenSSL/Custom issuer types
- PoliciesPage: create modal with type, severity, JSON config, enabled
- ProfilesPage: create modal with name, description, max TTL, short-lived
- OwnersPage: create modal with name, email, team dropdown
- TeamsPage: create modal with name, description
- AgentGroupsPage: create modal with match criteria fields
- Layout.tsx: version v2.0.5 → v2.0.7
- cmd/server/main.go: version 0.1.0 → 2.0.7

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
shankar0123
2026-03-28 07:36:58 -04:00
parent 9b5b9ad3a2
commit baafab50c5
8 changed files with 975 additions and 14 deletions
+1 -1
View File
@@ -44,7 +44,7 @@ func main() {
}))
logger.Info("certctl server starting",
"version", "0.1.0",
"version", "2.0.7",
"server_host", cfg.Server.Host,
"server_port", cfg.Server.Port)
+1 -1
View File
@@ -69,7 +69,7 @@ export default function Layout() {
</nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.5</span>
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.7</span>
{authRequired && (
<button
onClick={logout}
+166 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getAgentGroups, deleteAgentGroup } from '../api/client';
import { getAgentGroups, deleteAgentGroup, createAgentGroup } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -8,8 +9,148 @@ import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { AgentGroup } from '../api/types';
interface CreateAgentGroupModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
}
function CreateAgentGroupModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateAgentGroupModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [matchOs, setMatchOs] = useState('');
const [matchArch, setMatchArch] = useState('');
const [matchIpCidr, setMatchIpCidr] = useState('');
const [matchVersion, setMatchVersion] = useState('');
const [enabled, setEnabled] = useState(true);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createAgentGroup({
name: name.trim(),
description: description.trim(),
match_os: matchOs.trim() || undefined,
match_architecture: matchArch.trim() || undefined,
match_ip_cidr: matchIpCidr.trim() || undefined,
match_version: matchVersion.trim() || undefined,
enabled,
});
setName('');
setDescription('');
setMatchOs('');
setMatchArch('');
setMatchIpCidr('');
setMatchVersion('');
setEnabled(true);
onSuccess();
} catch (err) {
console.error('Create agent group error:', err);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Agent Group</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="e.g., Production Linux Servers"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="Optional description"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match OS</label>
<input
value={matchOs}
onChange={e => setMatchOs(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="linux"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match Architecture</label>
<input
value={matchArch}
onChange={e => setMatchArch(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="amd64"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match IP CIDR</label>
<input
value={matchIpCidr}
onChange={e => setMatchIpCidr(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="10.0.0.0/8"
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Match Version</label>
<input
value={matchVersion}
onChange={e => setMatchVersion(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="2.0.*"
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="enabled" className="text-sm text-ink">Enabled</label>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Group'}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 btn btn-ghost"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function AgentGroupsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['agent-groups'],
@@ -21,6 +162,14 @@ export default function AgentGroupsPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-groups'] }),
});
const createMutation = useMutation({
mutationFn: createAgentGroup,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
setShowCreate(false);
},
});
const columns: Column<AgentGroup>[] = [
{
key: 'name',
@@ -81,7 +230,15 @@ export default function AgentGroupsPage() {
return (
<>
<PageHeader title="Agent Groups" subtitle={data ? `${data.total} groups` : undefined} />
<PageHeader
title="Agent Groups"
subtitle={data ? `${data.total} groups` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ New Group
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
@@ -89,6 +246,13 @@ export default function AgentGroupsPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agent groups configured" />
)}
</div>
<CreateAgentGroupModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
</>
);
}
+297 -2
View File
@@ -1,6 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer } from '../api/client';
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -12,13 +12,79 @@ import type { Issuer } from '../api/types';
const typeLabels: Record<string, string> = {
local_ca: 'Local CA',
acme: 'ACME',
stepca: 'step-ca',
openssl: 'OpenSSL/Custom',
vault: 'Vault PKI',
manual: 'Manual',
};
interface IssuerConfigField {
key: string;
label: string;
placeholder?: string;
required: boolean;
type?: string;
options?: string[];
defaultValue?: string;
}
interface IssuerTypeConfig {
id: string;
name: string;
description: string;
configFields: IssuerConfigField[];
}
const issuerTypes: IssuerTypeConfig[] = [
{
id: 'local_ca',
name: 'Local CA',
description: 'Self-signed or subordinate CA for certificate issuance',
configFields: [
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false },
],
},
{
id: 'acme',
name: 'ACME',
description: "Let's Encrypt or other ACME-compatible CA",
configFields: [
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
],
},
{
id: 'stepca',
name: 'step-ca',
description: 'Smallstep private CA',
configFields: [
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true },
],
},
{
id: 'openssl',
name: 'OpenSSL/Custom',
description: 'Script-based signing with your own CA',
configFields: [
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
],
},
];
export default function IssuersPage() {
const queryClient = useQueryClient();
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false);
const [createStep, setCreateStep] = useState<'type' | 'config'>('type');
const [selectedType, setSelectedType] = useState<string | null>(null);
const [createForm, setCreateForm] = useState<Record<string, unknown>>({});
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['issuers'],
@@ -36,6 +102,18 @@ export default function IssuersPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['issuers'] }),
});
const createMutation = useMutation({
mutationFn: (data: { name: string; type: string; config: Record<string, unknown> }) =>
createIssuer(data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issuers'] });
setShowCreateModal(false);
setCreateStep('type');
setSelectedType(null);
setCreateForm({});
},
});
const columns: Column<Issuer>[] = [
{
key: 'name',
@@ -101,7 +179,23 @@ export default function IssuersPage() {
return (
<>
<PageHeader title="Issuers" subtitle={data ? `${data.total} issuers` : undefined} />
<PageHeader
title="Issuers"
subtitle={data ? `${data.total} issuers` : undefined}
action={
<button
onClick={() => {
setShowCreateModal(true);
setCreateStep('type');
setSelectedType(null);
setCreateForm({});
}}
className="px-4 py-2 bg-brand-600 text-white rounded font-medium hover:bg-brand-700 transition-colors text-sm"
>
+ New Issuer
</button>
}
/>
{testResult && (
<div className={`mx-6 mt-3 rounded px-4 py-3 text-sm ${testResult.ok ? 'bg-emerald-100 border border-emerald-200 text-emerald-700' : 'bg-red-50 border border-red-200 text-red-700'}`}>
{testResult.id}: {testResult.msg}
@@ -115,6 +209,207 @@ export default function IssuersPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
)}
</div>
{showCreateModal && (
<CreateIssuerModal
step={createStep}
selectedType={selectedType}
form={createForm}
onTypeSelect={(type) => {
setSelectedType(type);
const typeConfig = issuerTypes.find((t) => t.id === type);
const defaultConfig: Record<string, unknown> = {};
if (typeConfig) {
typeConfig.configFields.forEach((field) => {
if (field.defaultValue) {
defaultConfig[field.key] = field.defaultValue;
}
});
}
setCreateForm({ ...defaultConfig });
setCreateStep('config');
}}
onFormChange={(field, value) => {
setCreateForm({ ...createForm, [field]: value });
}}
onBack={() => setCreateStep('type')}
onSubmit={() => {
if (!selectedType || !createForm.name) return;
const config: Record<string, unknown> = { ...createForm };
const name = config.name as string;
delete config.name;
createMutation.mutate({ name, type: selectedType, config });
}}
onCancel={() => {
setShowCreateModal(false);
setCreateStep('type');
setSelectedType(null);
setCreateForm({});
}}
isSubmitting={createMutation.isPending}
/>
)}
</>
);
}
interface CreateIssuerModalProps {
step: 'type' | 'config';
selectedType: string | null;
form: Record<string, unknown>;
onTypeSelect: (type: string) => void;
onFormChange: (field: string, value: unknown) => void;
onBack: () => void;
onSubmit: () => void;
onCancel: () => void;
isSubmitting: boolean;
}
function CreateIssuerModal({
step,
selectedType,
form,
onTypeSelect,
onFormChange,
onBack,
onSubmit,
onCancel,
isSubmitting,
}: CreateIssuerModalProps) {
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType);
return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
<div className="bg-surface border border-surface-border rounded-lg shadow-lg max-w-2xl w-full mx-4">
{/* Header */}
<div className="border-b border-surface-border px-6 py-4 flex justify-between items-center">
<h2 className="text-lg font-semibold text-ink">
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
</h2>
<button
onClick={onCancel}
className="text-ink-muted hover:text-ink transition-colors"
>
</button>
</div>
{/* Content */}
<div className="px-6 py-6">
{step === 'type' ? (
<div className="grid grid-cols-2 gap-4">
{issuerTypes.map((type) => (
<button
key={type.id}
onClick={() => onTypeSelect(type.id)}
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
>
<div className="font-medium text-ink">{type.name}</div>
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
</button>
))}
</div>
) : (
<div className="space-y-5">
{/* Name field always shown */}
<div>
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
<input
type="text"
value={(form.name as string) || ''}
onChange={(e) => onFormChange('name', e.target.value)}
placeholder="e.g., Production CA"
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
</div>
{/* Type-specific fields */}
{selectedTypeConfig?.configFields.map((field) => (
<div key={field.key}>
<label className="block text-sm font-medium text-ink mb-2">
{field.label}
{field.required && <span className="text-red-600 ml-1">*</span>}
</label>
{field.type === 'select' ? (
<select
value={(form[field.key] as string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value)}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
>
<option value="">Select {field.label}</option>
{field.options?.map((opt) => (
<option key={opt} value={opt}>
{opt}
</option>
))}
</select>
) : field.type === 'textarea' ? (
<textarea
value={(form[field.key] as string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value)}
placeholder={field.placeholder}
rows={4}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors font-mono text-xs"
/>
) : field.type === 'number' ? (
<input
type="number"
value={(form[field.key] as number | string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value ? parseInt(e.target.value, 10) : '')}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
) : (
<input
type="text"
value={(form[field.key] as string) || ''}
onChange={(e) => onFormChange(field.key, e.target.value)}
placeholder={field.placeholder}
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
/>
)}
</div>
))}
</div>
)}
</div>
{/* Footer */}
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
{step === 'config' && (
<button
onClick={onBack}
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
>
Back
</button>
)}
<button
onClick={onCancel}
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
>
Cancel
</button>
{step === 'config' && (
<button
onClick={onSubmit}
disabled={isSubmitting || !form.name}
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{isSubmitting ? 'Creating...' : 'Create Issuer'}
</button>
)}
{step === 'type' && (
<button
onClick={() => selectedType && onTypeSelect(selectedType)}
disabled={!selectedType}
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
Next
</button>
)}
</div>
</div>
</div>
);
}
+126 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getOwners, getTeams, deleteOwner } from '../api/client';
import { getOwners, getTeams, deleteOwner, createOwner } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -7,8 +8,107 @@ import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Owner, Team } from '../api/types';
interface CreateOwnerModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
teamsData?: { data: Team[] };
}
function CreateOwnerModal({ isOpen, onClose, onSuccess, isLoading, error, teamsData }: CreateOwnerModalProps) {
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [teamId, setTeamId] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim() || !email.trim()) return;
try {
await createOwner({
name: name.trim(),
email: email.trim(),
team_id: teamId || undefined,
});
setName('');
setEmail('');
setTeamId('');
onSuccess();
} catch (err) {
console.error('Create owner error:', err);
}
};
if (!isOpen) return null;
const teams = teamsData?.data || [];
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Owner</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="e.g., Alice Smith"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Email *</label>
<input
type="email"
value={email}
onChange={e => setEmail(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="alice@example.com"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Team</label>
<select
value={teamId}
onChange={e => setTeamId(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
>
<option value="">Unassigned</option>
{teams.map(team => (
<option key={team.id} value={team.id}>{team.name}</option>
))}
</select>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Owner'}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 btn btn-ghost"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function OwnersPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['owners'],
@@ -26,6 +126,14 @@ export default function OwnersPage() {
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
});
const createMutation = useMutation({
mutationFn: createOwner,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['owners'] });
setShowCreate(false);
},
});
const teamMap = new Map<string, Team>();
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
@@ -76,7 +184,15 @@ export default function OwnersPage() {
return (
<>
<PageHeader title="Owners" subtitle={data ? `${data.total} owners` : undefined} />
<PageHeader
title="Owners"
subtitle={data ? `${data.total} owners` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ New Owner
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
@@ -84,6 +200,14 @@ export default function OwnersPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No owners configured" />
)}
</div>
<CreateOwnerModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
teamsData={teamsData}
/>
</>
);
}
+146 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getPolicies, updatePolicy, deletePolicy } from '../api/client';
import { getPolicies, updatePolicy, deletePolicy, createPolicy } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -21,8 +22,128 @@ const severityDots: Record<string, string> = {
critical: 'bg-red-500',
};
interface CreatePolicyModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
}
function CreatePolicyModal({ isOpen, onClose, onSuccess, isLoading, error }: CreatePolicyModalProps) {
const [name, setName] = useState('');
const [type, setType] = useState('key_algorithm');
const [severity, setSeverity] = useState('medium');
const [configStr, setConfigStr] = useState('{}');
const [enabled, setEnabled] = useState(true);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
const config = JSON.parse(configStr);
await createPolicy({ name: name.trim(), type, severity, config, enabled });
setName('');
setType('key_algorithm');
setSeverity('medium');
setConfigStr('{}');
setEnabled(true);
onSuccess();
} catch (err) {
console.error('Create policy error:', err);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Policy</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="e.g., Key Length Enforcement"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Type *</label>
<select
value={type}
onChange={e => setType(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
>
<option value="key_algorithm">Key Algorithm</option>
<option value="cert_lifetime">Certificate Lifetime</option>
<option value="san_pattern">SAN Pattern</option>
<option value="key_usage">Key Usage</option>
<option value="revocation_check">Revocation Check</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Severity *</label>
<select
value={severity}
onChange={e => setSeverity(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
<option value="critical">Critical</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Config (JSON)</label>
<textarea
value={configStr}
onChange={e => setConfigStr(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink font-mono focus:outline-none focus:border-brand-400"
placeholder='{"key": "value"}'
rows={3}
/>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="enabled"
checked={enabled}
onChange={e => setEnabled(e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="enabled" className="text-sm text-ink">Enabled</label>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Policy'}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 btn btn-ghost"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function PoliciesPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['policies'],
@@ -39,6 +160,14 @@ export default function PoliciesPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
});
const createMutation = useMutation({
mutationFn: createPolicy,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['policies'] });
setShowCreate(false);
},
});
const policies = data?.data || [];
const enabledCount = policies.filter(p => p.enabled).length;
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
@@ -104,7 +233,15 @@ export default function PoliciesPage() {
return (
<>
<PageHeader title="Policies" subtitle={data ? `${data.total} rules` : undefined} />
<PageHeader
title="Policies"
subtitle={data ? `${data.total} rules` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ New Policy
</button>
}
/>
{policies.length > 0 && (
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-surface-border/50">
<div className="flex items-center gap-2">
@@ -129,6 +266,13 @@ export default function PoliciesPage() {
<DataTable columns={columns} data={policies} isLoading={isLoading} emptyMessage="No policy rules" />
)}
</div>
<CreatePolicyModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
</>
);
}
+133 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getProfiles, deleteProfile } from '../api/client';
import { getProfiles, deleteProfile, createProfile } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -16,8 +17,115 @@ function formatTTL(seconds: number): string {
return `${Math.floor(seconds / 86400)}d`;
}
interface CreateProfileModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
}
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [ttl, setTtl] = useState('86400');
const [shortLived, setShortLived] = useState(false);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createProfile({
name: name.trim(),
description: description.trim(),
max_ttl_seconds: parseInt(ttl) || 86400,
allow_short_lived: shortLived,
enabled: true,
});
setName('');
setDescription('');
setTtl('86400');
setShortLived(false);
onSuccess();
} catch (err) {
console.error('Create profile error:', err);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Profile</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="e.g., Web Server Certs"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="Optional description"
rows={2}
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Max TTL (seconds)</label>
<input
type="number"
value={ttl}
onChange={e => setTtl(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="86400"
/>
<p className="text-xs text-ink-muted mt-1">e.g. 86400 = 1 day, 2592000 = 30 days</p>
</div>
<div className="flex items-center gap-2">
<input
type="checkbox"
id="shortLived"
checked={shortLived}
onChange={e => setShortLived(e.target.checked)}
className="w-4 h-4"
/>
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Profile'}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 btn btn-ghost"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function ProfilesPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['profiles'],
@@ -29,6 +137,14 @@ export default function ProfilesPage() {
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profiles'] }),
});
const createMutation = useMutation({
mutationFn: createProfile,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['profiles'] });
setShowCreate(false);
},
});
const columns: Column<CertificateProfile>[] = [
{
key: 'name',
@@ -116,7 +232,15 @@ export default function ProfilesPage() {
return (
<>
<PageHeader title="Certificate Profiles" subtitle={data ? `${data.total} profiles` : undefined} />
<PageHeader
title="Certificate Profiles"
subtitle={data ? `${data.total} profiles` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ New Profile
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
@@ -124,6 +248,13 @@ export default function ProfilesPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No profiles configured" />
)}
</div>
<CreateProfileModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
</>
);
}
+105 -2
View File
@@ -1,5 +1,6 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTeams, deleteTeam } from '../api/client';
import { getTeams, deleteTeam, createTeam } from '../api/client';
import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable';
@@ -7,8 +8,87 @@ import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils';
import type { Team } from '../api/types';
interface CreateTeamModalProps {
isOpen: boolean;
onClose: () => void;
onSuccess: () => void;
isLoading: boolean;
error: string | null;
}
function CreateTeamModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateTeamModalProps) {
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!name.trim()) return;
try {
await createTeam({
name: name.trim(),
description: description.trim(),
});
setName('');
setDescription('');
onSuccess();
} catch (err) {
console.error('Create team error:', err);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}>
<div className="bg-surface border border-surface-border rounded p-5 w-full max-w-md shadow-xl" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Team</h2>
{error && <div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">{error}</div>}
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name *</label>
<input
value={name}
onChange={e => setName(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="e.g., Platform Team"
required
/>
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Description</label>
<textarea
value={description}
onChange={e => setDescription(e.target.value)}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
placeholder="Optional team description"
rows={2}
/>
</div>
<div className="flex gap-2 pt-4">
<button
type="submit"
disabled={isLoading}
className="flex-1 btn btn-primary disabled:opacity-50 disabled:cursor-not-allowed"
>
{isLoading ? 'Creating...' : 'Create Team'}
</button>
<button
type="button"
onClick={onClose}
className="flex-1 btn btn-ghost"
>
Cancel
</button>
</div>
</form>
</div>
</div>
);
}
export default function TeamsPage() {
const queryClient = useQueryClient();
const [showCreate, setShowCreate] = useState(false);
const { data, isLoading, error, refetch } = useQuery({
queryKey: ['teams'],
@@ -21,6 +101,14 @@ export default function TeamsPage() {
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
});
const createMutation = useMutation({
mutationFn: createTeam,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['teams'] });
setShowCreate(false);
},
});
const columns: Column<Team>[] = [
{
key: 'name',
@@ -60,7 +148,15 @@ export default function TeamsPage() {
return (
<>
<PageHeader title="Teams" subtitle={data ? `${data.total} teams` : undefined} />
<PageHeader
title="Teams"
subtitle={data ? `${data.total} teams` : undefined}
action={
<button onClick={() => setShowCreate(true)} className="btn btn-primary">
+ New Team
</button>
}
/>
<div className="flex-1 overflow-y-auto">
{error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} />
@@ -68,6 +164,13 @@ export default function TeamsPage() {
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No teams configured" />
)}
</div>
<CreateTeamModal
isOpen={showCreate}
onClose={() => setShowCreate(false)}
onSuccess={() => {}}
isLoading={createMutation.isPending}
error={createMutation.error ? (createMutation.error as Error).message : null}
/>
</>
);
}