mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 13:58:59 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6da60d1287 | |||
| baafab50c5 |
@@ -24,7 +24,7 @@ certctl is a self-hosted platform that automates the entire certificate lifecycl
|
|||||||
|
|
||||||
[](LICENSE)
|
[](LICENSE)
|
||||||
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
[](https://goreportcard.com/report/github.com/shankar0123/certctl)
|
||||||

|
[](https://github.com/shankar0123/certctl/releases)
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -44,7 +44,7 @@ func main() {
|
|||||||
}))
|
}))
|
||||||
|
|
||||||
logger.Info("certctl server starting",
|
logger.Info("certctl server starting",
|
||||||
"version", "0.1.0",
|
"version", "2.0.8",
|
||||||
"server_host", cfg.Server.Host,
|
"server_host", cfg.Server.Host,
|
||||||
"server_port", cfg.Server.Port)
|
"server_port", cfg.Server.Port)
|
||||||
|
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export default function Layout() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
<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.8</span>
|
||||||
{authRequired && (
|
{authRequired && (
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -8,8 +9,148 @@ import ErrorState from '../components/ErrorState';
|
|||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { AgentGroup } from '../api/types';
|
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() {
|
export default function AgentGroupsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['agent-groups'],
|
queryKey: ['agent-groups'],
|
||||||
@@ -21,6 +162,14 @@ export default function AgentGroupsPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-groups'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['agent-groups'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createAgentGroup,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['agent-groups'] });
|
||||||
|
setShowCreate(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns: Column<AgentGroup>[] = [
|
const columns: Column<AgentGroup>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -81,7 +230,15 @@ export default function AgentGroupsPage() {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
<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" />
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No agent groups configured" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CreateAgentGroupModal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -12,13 +12,79 @@ import type { Issuer } from '../api/types';
|
|||||||
const typeLabels: Record<string, string> = {
|
const typeLabels: Record<string, string> = {
|
||||||
local_ca: 'Local CA',
|
local_ca: 'Local CA',
|
||||||
acme: 'ACME',
|
acme: 'ACME',
|
||||||
|
stepca: 'step-ca',
|
||||||
|
openssl: 'OpenSSL/Custom',
|
||||||
vault: 'Vault PKI',
|
vault: 'Vault PKI',
|
||||||
manual: 'Manual',
|
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() {
|
export default function IssuersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
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({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['issuers'],
|
queryKey: ['issuers'],
|
||||||
@@ -36,6 +102,18 @@ export default function IssuersPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['issuers'] }),
|
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>[] = [
|
const columns: Column<Issuer>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -101,7 +179,23 @@ export default function IssuersPage() {
|
|||||||
|
|
||||||
return (
|
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 && (
|
{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'}`}>
|
<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}
|
{testResult.id}: {testResult.msg}
|
||||||
@@ -115,6 +209,207 @@ export default function IssuersPage() {
|
|||||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -7,8 +8,107 @@ import ErrorState from '../components/ErrorState';
|
|||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Owner, Team } from '../api/types';
|
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() {
|
export default function OwnersPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['owners'],
|
queryKey: ['owners'],
|
||||||
@@ -26,6 +126,14 @@ export default function OwnersPage() {
|
|||||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
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>();
|
const teamMap = new Map<string, Team>();
|
||||||
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
|
(teamsData?.data || []).forEach((t) => teamMap.set(t.id, t));
|
||||||
|
|
||||||
@@ -76,7 +184,15 @@ export default function OwnersPage() {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
<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" />
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No owners configured" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CreateOwnerModal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
|
teamsData={teamsData}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -21,8 +22,128 @@ const severityDots: Record<string, string> = {
|
|||||||
critical: 'bg-red-500',
|
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() {
|
export default function PoliciesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['policies'],
|
queryKey: ['policies'],
|
||||||
@@ -39,6 +160,14 @@ export default function PoliciesPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['policies'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createPolicy,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['policies'] });
|
||||||
|
setShowCreate(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const policies = data?.data || [];
|
const policies = data?.data || [];
|
||||||
const enabledCount = policies.filter(p => p.enabled).length;
|
const enabledCount = policies.filter(p => p.enabled).length;
|
||||||
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
|
const bySeverity = policies.reduce<Record<string, number>>((acc, p) => {
|
||||||
@@ -104,7 +233,15 @@ export default function PoliciesPage() {
|
|||||||
|
|
||||||
return (
|
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 && (
|
{policies.length > 0 && (
|
||||||
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-surface-border/50">
|
<div className="px-4 py-3 flex flex-wrap gap-4 border-b border-surface-border/50">
|
||||||
<div className="flex items-center gap-2">
|
<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" />
|
<DataTable columns={columns} data={policies} isLoading={isLoading} emptyMessage="No policy rules" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CreatePolicyModal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -16,8 +17,115 @@ function formatTTL(seconds: number): string {
|
|||||||
return `${Math.floor(seconds / 86400)}d`;
|
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() {
|
export default function ProfilesPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['profiles'],
|
queryKey: ['profiles'],
|
||||||
@@ -29,6 +137,14 @@ export default function ProfilesPage() {
|
|||||||
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profiles'] }),
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['profiles'] }),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createProfile,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['profiles'] });
|
||||||
|
setShowCreate(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns: Column<CertificateProfile>[] = [
|
const columns: Column<CertificateProfile>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -116,7 +232,15 @@ export default function ProfilesPage() {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
<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" />
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No profiles configured" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CreateProfileModal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+105
-2
@@ -1,5 +1,6 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
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 PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
@@ -7,8 +8,87 @@ import ErrorState from '../components/ErrorState';
|
|||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Team } from '../api/types';
|
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() {
|
export default function TeamsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['teams'],
|
queryKey: ['teams'],
|
||||||
@@ -21,6 +101,14 @@ export default function TeamsPage() {
|
|||||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: createTeam,
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['teams'] });
|
||||||
|
setShowCreate(false);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const columns: Column<Team>[] = [
|
const columns: Column<Team>[] = [
|
||||||
{
|
{
|
||||||
key: 'name',
|
key: 'name',
|
||||||
@@ -60,7 +148,15 @@ export default function TeamsPage() {
|
|||||||
|
|
||||||
return (
|
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">
|
<div className="flex-1 overflow-y-auto">
|
||||||
{error ? (
|
{error ? (
|
||||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
<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" />
|
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No teams configured" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
<CreateTeamModal
|
||||||
|
isOpen={showCreate}
|
||||||
|
onClose={() => setShowCreate(false)}
|
||||||
|
onSuccess={() => {}}
|
||||||
|
isLoading={createMutation.isPending}
|
||||||
|
error={createMutation.error ? (createMutation.error as Error).message : null}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user