mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 20:31:30 +00:00
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:
+1
-1
@@ -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)
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user