mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-07 18:11:32 +00:00
feat: add issuer catalog page with type discovery + fix cert creation defaults (M33)
Issuer Catalog (M33): - Shared issuer type config (issuerTypes.ts) with 6 supported + 2 coming-soon types - Composable wizard components (TypeSelector, ConfigForm, ConfigDetailModal) - Catalog card layout with Connected/Available/Coming Soon badges - VaultPKI and DigiCert added to create wizard with full config fields - ACME EAB fields (eab_kid, eab_hmac with sensitive flag) - Issuer type filter dropdown on configured issuers table - Config detail modal replacing 60-char truncation - IssuerDetailPage uses shared typeLabels/redactConfig, Edit button, enabled/disabled status - StatusBadge extended with Enabled/Disabled styles - 2 new frontend tests (VaultPKI + DigiCert create payload verification) Bug fixes: - CertificateService.CreateCertificate now defaults Status to Pending and Tags to empty map when not set (DB column DEFAULTs only apply when columns are omitted from INSERT, but our repo always includes all columns) - CreateCertificate handler now logs actual error via slog.Error before returning generic 500, enabling root cause debugging Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+105
-3
@@ -44,6 +44,7 @@ Comprehensive manual testing playbook. Every test has a concrete command, an exp
|
||||
- [Part 37: GUI Completeness (Pre-2.1.0-E)](#part-37-gui-completeness-pre-210-e)
|
||||
- [Part 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
||||
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37)
|
||||
- [Part 40: Issuer Catalog Page (M33)](#part-40-issuer-catalog-page-m33)
|
||||
- [Release Sign-Off](#release-sign-off)
|
||||
|
||||
---
|
||||
@@ -5372,6 +5373,88 @@ curl -s -X POST -H "$AUTH" \
|
||||
|
||||
---
|
||||
|
||||
## Part 40: Issuer Catalog Page (M33)
|
||||
|
||||
Frontend-only milestone. No backend changes. All tests are automated via `qa-smoke-test.sh` and `vitest`.
|
||||
|
||||
### 40.1 Shared Issuer Type Config
|
||||
|
||||
**Test:** Verify shared config file exists with all 6 supported types + 2 coming soon stubs.
|
||||
|
||||
```bash
|
||||
test -f web/src/config/issuerTypes.ts
|
||||
grep -c 'VaultPKI' web/src/config/issuerTypes.ts # >= 1
|
||||
grep -c 'DigiCert' web/src/config/issuerTypes.ts # >= 1
|
||||
grep -cE 'eab_kid|eab_hmac' web/src/config/issuerTypes.ts # >= 1
|
||||
grep -c 'sensitive' web/src/config/issuerTypes.ts # >= 1
|
||||
```
|
||||
|
||||
**PASS if** file exists, all types present, EAB fields and sensitive flags included.
|
||||
|
||||
### 40.2 Composable Wizard Components
|
||||
|
||||
**Test:** Verify reusable components exist.
|
||||
|
||||
```bash
|
||||
test -f web/src/components/issuer/TypeSelector.tsx
|
||||
test -f web/src/components/issuer/ConfigForm.tsx
|
||||
test -f web/src/components/issuer/ConfigDetailModal.tsx
|
||||
```
|
||||
|
||||
**PASS if** all 3 component files exist.
|
||||
|
||||
### 40.3 Frontend Build
|
||||
|
||||
**Test:** Verify frontend builds with zero errors.
|
||||
|
||||
```bash
|
||||
cd web && npm run build 2>&1 | tail -1 | grep -q 'built in'
|
||||
```
|
||||
|
||||
**PASS if** build succeeds.
|
||||
|
||||
### 40.4 Frontend Tests
|
||||
|
||||
**Test:** Verify all Vitest tests pass including new VaultPKI/DigiCert create tests.
|
||||
|
||||
```bash
|
||||
cd web && npx vitest run 2>&1 | grep -qE 'Tests.*passed'
|
||||
```
|
||||
|
||||
**PASS if** all tests pass.
|
||||
|
||||
### 40.5 (Manual) Create VaultPKI Issuer via Wizard
|
||||
|
||||
**Test:** Open Issuers page, click "Configure" on Vault PKI card, fill in form (addr, token, mount, role, ttl), submit.
|
||||
**PASS if** issuer appears in configured issuers table.
|
||||
|
||||
### 40.6 (Manual) Create DigiCert Issuer via Wizard
|
||||
|
||||
**Test:** Open Issuers page, click "Configure" on DigiCert card, fill in form (api_key, org_id, product_type), submit.
|
||||
**PASS if** issuer appears in configured issuers table.
|
||||
|
||||
### 40.7 (Manual) Create ACME Issuer with EAB Fields
|
||||
|
||||
**Test:** Open create wizard, select ACME, verify EAB Key ID and EAB HMAC Key fields are visible.
|
||||
**PASS if** EAB fields render and accept input.
|
||||
|
||||
### 40.8 (Manual) Catalog Cards Show Correct Status
|
||||
|
||||
**Test:** Verify catalog cards show "Connected" (green, count) for types with configured issuers, "Available" (blue) for unconfigured types, and "Coming Soon" (grey) for Sectigo/Entrust.
|
||||
**PASS if** all 8 cards render with correct status.
|
||||
|
||||
### 40.9 (Manual) Config Detail Modal Shows Full Redacted Config
|
||||
|
||||
**Test:** Click "View Config" on a configured issuer row. Verify modal shows full config JSON with sensitive fields (token, key, hmac, password, private, secret) redacted as `********`.
|
||||
**PASS if** modal opens, full config visible, sensitive fields redacted.
|
||||
|
||||
### 40.10 (Manual) Issuer Type Filter Works
|
||||
|
||||
**Test:** Use the type filter dropdown above the configured issuers table. Select a specific type.
|
||||
**PASS if** table filters to show only issuers of the selected type.
|
||||
|
||||
---
|
||||
|
||||
## Release Sign-Off
|
||||
|
||||
All tests below must pass before tagging v2.1.0. Each row is one individual test from the guide above. The **Method** column indicates whether `qa-smoke-test.sh` covers the test automatically (**Auto**) or requires hands-on verification (**Manual**).
|
||||
@@ -5952,14 +6035,33 @@ These must be green before starting manual QA:
|
||||
| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
| 39.5 | Revocation records locally | Manual | ☐ | | Requires DigiCert sandbox |
|
||||
|
||||
### Part 40: Issuer Catalog Page (M33)
|
||||
|
||||
| Test | Description | Method | Pass? | Date | Notes |
|
||||
|------|-------------|--------|-------|------|-------|
|
||||
| 40.s1 | Shared issuerTypes config exists | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.1 |
|
||||
| 40.s2 | VaultPKI in issuerTypes config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.2 |
|
||||
| 40.s3 | DigiCert in issuerTypes config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.3 |
|
||||
| 40.s4 | ACME EAB fields in config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.4 |
|
||||
| 40.s5 | Sensitive field flag in config | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.5 |
|
||||
| 40.s6 | ConfigDetailModal component exists | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.6 |
|
||||
| 40.s7 | Frontend build succeeds | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.7 |
|
||||
| 40.s8 | Frontend tests pass | Auto | ☑ | 2026-03-30 | qa-smoke-test.sh 40.8 |
|
||||
| 40.m1 | Create VaultPKI issuer via wizard | Manual | ☐ | | |
|
||||
| 40.m2 | Create DigiCert issuer via wizard | Manual | ☐ | | |
|
||||
| 40.m3 | Create ACME issuer with EAB fields | Manual | ☐ | | |
|
||||
| 40.m4 | Catalog cards show correct status | Manual | ☐ | | |
|
||||
| 40.m5 | Config detail modal shows full redacted config | Manual | ☐ | | |
|
||||
| 40.m6 | Issuer type filter works | Manual | ☐ | | |
|
||||
|
||||
### Summary
|
||||
|
||||
| Category | Count |
|
||||
|----------|-------|
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 136 |
|
||||
| ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
|
||||
| — Skipped (preconditions not met in demo) | 5 |
|
||||
| ☐ Manual (requires hands-on verification) | 226 |
|
||||
| **Total** | **367** |
|
||||
| ☐ Manual (requires hands-on verification) | 232 |
|
||||
| **Total** | **381** |
|
||||
|
||||
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
created, err := h.svc.CreateCertificate(cert)
|
||||
if err != nil {
|
||||
slog.Error("failed to create certificate", "error", err, "request_id", requestID, "common_name", cert.CommonName, "name", cert.Name)
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -304,6 +304,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
||||
if cert.UpdatedAt.IsZero() {
|
||||
cert.UpdatedAt = now
|
||||
}
|
||||
// Default status to Pending if not set (DB column DEFAULT only applies when column is omitted from INSERT)
|
||||
if cert.Status == "" {
|
||||
cert.Status = domain.CertificateStatusPending
|
||||
}
|
||||
// Default tags to empty map if nil (avoids JSON null in JSONB column)
|
||||
if cert.Tags == nil {
|
||||
cert.Tags = make(map[string]string)
|
||||
}
|
||||
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
@@ -632,6 +632,50 @@ describe('API Client', () => {
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('createIssuer sends correct payload for VaultPKI type', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-vault', name: 'Vault PKI' }));
|
||||
const vaultPayload = {
|
||||
name: 'Vault PKI',
|
||||
type: 'VaultPKI',
|
||||
config: {
|
||||
addr: 'https://vault.internal:8200',
|
||||
token: 'hvs.test-token',
|
||||
mount: 'pki',
|
||||
role: 'web-certs',
|
||||
ttl: '8760h',
|
||||
},
|
||||
};
|
||||
await createIssuer(vaultPayload);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.type).toBe('VaultPKI');
|
||||
expect(body.config.addr).toBe('https://vault.internal:8200');
|
||||
expect(body.config.role).toBe('web-certs');
|
||||
});
|
||||
|
||||
it('createIssuer sends correct payload for DigiCert type', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-digicert', name: 'DigiCert' }));
|
||||
const digicertPayload = {
|
||||
name: 'DigiCert CertCentral',
|
||||
type: 'DigiCert',
|
||||
config: {
|
||||
api_key: 'test-api-key',
|
||||
org_id: '12345',
|
||||
product_type: 'ssl_basic',
|
||||
},
|
||||
};
|
||||
await createIssuer(digicertPayload);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.type).toBe('DigiCert');
|
||||
expect(body.config.org_id).toBe('12345');
|
||||
expect(body.config.product_type).toBe('ssl_basic');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Audit ──────────────────────────────────────────
|
||||
|
||||
@@ -135,6 +135,8 @@ export interface Issuer {
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
/** Backend returns enabled boolean; status is derived from this */
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,9 @@ const statusStyles: Record<string, string> = {
|
||||
Unmanaged: 'badge-warning',
|
||||
Managed: 'badge-success',
|
||||
Dismissed: 'badge-neutral',
|
||||
// Issuer statuses
|
||||
Enabled: 'badge-success',
|
||||
Disabled: 'badge-neutral',
|
||||
// Notification statuses
|
||||
sent: 'badge-success',
|
||||
pending: 'badge-warning',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Full config viewer modal with sensitive field redaction.
|
||||
* Replaces the 60-char truncation in the issuers table.
|
||||
* Reusable for targets in M35 — no IssuersPage-specific imports.
|
||||
*/
|
||||
import { isSensitiveKey } from '../../config/issuerTypes';
|
||||
|
||||
interface ConfigDetailModalProps {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ConfigDetailModal({ title, config, onClose }: ConfigDetailModalProps) {
|
||||
const entries = Object.entries(config);
|
||||
|
||||
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-lg w-full mx-4">
|
||||
<div className="border-b border-surface-border px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 max-h-96 overflow-y-auto">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{entries.map(([key, val]) => {
|
||||
const redacted = isSensitiveKey(key);
|
||||
return (
|
||||
<div key={key} className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{key}</span>
|
||||
<span className="text-sm text-ink font-mono text-right max-w-xs break-all">
|
||||
{redacted ? '********' : String(val ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Renders config fields from an IssuerTypeConfig.configFields definition.
|
||||
* Handles sensitive field masking. M34 will reuse this directly for its
|
||||
* dynamic config wizard. M35 can reuse it for target config forms.
|
||||
*/
|
||||
import type { ConfigField } from '../../config/issuerTypes';
|
||||
|
||||
interface ConfigFormProps {
|
||||
fields: ConfigField[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
/** When true, sensitive fields show as ******** with a "Change" button.
|
||||
* Used in edit mode — empty value means "keep existing". */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfigForm({ fields, values, onChange, editMode }: ConfigFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((field) => (
|
||||
<ConfigFieldInput
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
editMode={editMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
editMode,
|
||||
}: {
|
||||
field: ConfigField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
const inputCls =
|
||||
'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';
|
||||
|
||||
// In edit mode, sensitive fields that haven't been touched show as masked
|
||||
if (editMode && field.sensitive && value === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-ink-muted font-mono">********</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="text-xs text-brand-400 hover:text-brand-500"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<select
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<textarea
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
className={`${inputCls} font-mono text-xs`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number | string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
placeholder={field.placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text or password
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<input
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ field }: { field: ConfigField }) {
|
||||
return (
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
{field.sensitive && (
|
||||
<span className="ml-2 text-xs text-yellow-500 font-normal">sensitive</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Issuer type selector grid. Used in both the catalog view and create wizard.
|
||||
* M34 will reuse this for its 3-step wizard (Select Type step).
|
||||
*/
|
||||
import { issuerTypes, type IssuerTypeConfig } from '../../config/issuerTypes';
|
||||
|
||||
interface TypeSelectorProps {
|
||||
onSelect: (typeId: string) => void;
|
||||
/** Filter to only show these type IDs. If not provided, shows all non-comingSoon types. */
|
||||
filterIds?: string[];
|
||||
}
|
||||
|
||||
export default function TypeSelector({ onSelect, filterIds }: TypeSelectorProps) {
|
||||
const types = filterIds
|
||||
? issuerTypes.filter(t => filterIds.includes(t.id))
|
||||
: issuerTypes.filter(t => !t.comingSoon);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{types.map((type: IssuerTypeConfig) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onSelect(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="flex items-center gap-2">
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="font-medium text-ink">{type.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Shared issuer type configuration.
|
||||
* Imported by IssuersPage.tsx (M33), and will be reused by M34 (Dynamic Issuer Config)
|
||||
* for its 3-step wizard config forms.
|
||||
*/
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'text' | 'password' | 'number' | 'select' | 'textarea';
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
/** Mark fields that contain secrets (tokens, keys, passwords).
|
||||
* Display as ******** when viewing existing config. M34 will use this
|
||||
* for AES-GCM encryption decisions. */
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuerTypeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
configFields: ConfigField[];
|
||||
/** If true, this type is not yet implemented — show as "Coming Soon" */
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical type label map. Keys match what the backend API returns.
|
||||
* DB stores: local, acme, stepca, openssl, VaultPKI, DigiCert
|
||||
*/
|
||||
export const typeLabels: Record<string, string> = {
|
||||
local: 'Local CA',
|
||||
local_ca: 'Local CA', // backward compat (some frontend references)
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
VaultPKI: 'Vault PKI',
|
||||
DigiCert: 'DigiCert',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
/**
|
||||
* All supported issuer types + 2 "Coming Soon" stubs.
|
||||
* Order: most common first, coming-soon last.
|
||||
*/
|
||||
export const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt, ZeroSSL, or any ACME-compatible CA",
|
||||
icon: '\uD83D\uDD12',
|
||||
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' },
|
||||
{ key: 'eab_kid', label: 'EAB Key ID', placeholder: 'External Account Binding Key ID (optional)', required: false },
|
||||
{ key: 'eab_hmac', label: 'EAB HMAC Key', placeholder: 'External Account Binding HMAC key', required: false, type: 'password', sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for internal certificates',
|
||||
icon: '\uD83C\uDFE0',
|
||||
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, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA with JWK provisioner auth',
|
||||
icon: '\uD83D\uDC63',
|
||||
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, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'VaultPKI',
|
||||
name: 'Vault PKI',
|
||||
description: 'HashiCorp Vault PKI secrets engine',
|
||||
icon: '\uD83D\uDD10',
|
||||
configFields: [
|
||||
{ key: 'addr', label: 'Vault Address', placeholder: 'https://vault.internal:8200', required: true },
|
||||
{ key: 'token', label: 'Vault Token', placeholder: 'hvs.CAES...', required: true, type: 'password', sensitive: true },
|
||||
{ key: 'mount', label: 'PKI Mount Path', placeholder: 'pki', required: false, defaultValue: 'pki' },
|
||||
{ key: 'role', label: 'PKI Role Name', placeholder: 'web-certs', required: true },
|
||||
{ key: 'ttl', label: 'Certificate TTL', placeholder: '8760h', required: false, defaultValue: '8760h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'DigiCert',
|
||||
name: 'DigiCert CertCentral',
|
||||
description: 'DigiCert CertCentral for OV/EV certificates',
|
||||
icon: '\uD83C\uDF10',
|
||||
configFields: [
|
||||
{ key: 'api_key', label: 'DigiCert API Key', placeholder: 'Your DigiCert API key', required: true, type: 'password', sensitive: true },
|
||||
{ key: 'org_id', label: 'Organization ID', placeholder: '12345', required: true },
|
||||
{ key: 'product_type', label: 'Product Type', type: 'select', options: ['ssl_basic', 'ssl_plus', 'ssl_wildcard', 'ssl_ev_basic', 'ssl_ev_plus'], required: false, defaultValue: 'ssl_basic' },
|
||||
{ key: 'base_url', label: 'API Base URL Override', placeholder: 'https://www.digicert.com/services/v2', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
icon: '\uD83D\uDD27',
|
||||
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 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sectigo',
|
||||
name: 'Sectigo',
|
||||
description: 'Sectigo Certificate Manager \u2014 coming soon',
|
||||
icon: '\uD83D\uDCE6',
|
||||
configFields: [],
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'entrust',
|
||||
name: 'Entrust',
|
||||
description: 'Entrust Certificate Services \u2014 coming soon',
|
||||
icon: '\uD83D\uDCE6',
|
||||
configFields: [],
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Sensitive config key patterns for redaction in display */
|
||||
const SENSITIVE_PATTERNS = ['password', 'secret', 'token', 'key', 'hmac', 'private'];
|
||||
|
||||
/** Check if a config key should be redacted */
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return SENSITIVE_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
/** Redact sensitive values in a config object */
|
||||
export function redactConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).map(([k, v]) => [k, isSensitiveKey(k) ? '********' : v])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns catalog status info per issuer type.
|
||||
* M36 (Onboarding) will use this to detect first-run state.
|
||||
*/
|
||||
export function getIssuerCatalogStatus(
|
||||
configuredIssuers: { type: string }[]
|
||||
): { type: IssuerTypeConfig; status: 'connected' | 'available' | 'coming_soon'; count: number }[] {
|
||||
return issuerTypes.map(t => {
|
||||
if (t.comingSoon) {
|
||||
return { type: t, status: 'coming_soon' as const, count: 0 };
|
||||
}
|
||||
// Match both the canonical id and common aliases
|
||||
const aliases: Record<string, string[]> = {
|
||||
local: ['local', 'local_ca'],
|
||||
};
|
||||
const matchIds = aliases[t.id] || [t.id];
|
||||
const matching = configuredIssuers.filter(i => matchIds.includes(i.type));
|
||||
return {
|
||||
type: t,
|
||||
status: matching.length > 0 ? 'connected' as const : 'available' as const,
|
||||
count: matching.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -7,15 +7,8 @@ import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Certificate } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME (Let\'s Encrypt)',
|
||||
step_ca: 'step-ca',
|
||||
openssl: 'OpenSSL / Custom',
|
||||
vault: 'Vault PKI',
|
||||
};
|
||||
import type { Certificate, Issuer } from '../api/types';
|
||||
import { typeLabels, redactConfig } from '../config/issuerTypes';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
@@ -26,8 +19,17 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive display status from backend enabled boolean */
|
||||
function issuerStatus(issuer: Issuer): string {
|
||||
if (issuer.enabled !== undefined) {
|
||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
return issuer.status || 'Unknown';
|
||||
}
|
||||
|
||||
export default function IssuerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: issuer, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuer', id],
|
||||
@@ -65,13 +67,7 @@ export default function IssuerDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Redact sensitive config fields
|
||||
const safeConfig = issuer.config ? Object.fromEntries(
|
||||
Object.entries(issuer.config).map(([k, v]) => {
|
||||
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
|
||||
return [k, sensitive ? '********' : v];
|
||||
})
|
||||
) : {};
|
||||
const safeConfig = issuer.config ? redactConfig(issuer.config) : {};
|
||||
|
||||
const certColumns: Column<Certificate>[] = [
|
||||
{
|
||||
@@ -94,13 +90,21 @@ export default function IssuerDetailPage() {
|
||||
title={issuer.name}
|
||||
subtitle={typeLabels[issuer.type] || issuer.type}
|
||||
action={
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/issuers?edit=${issuer.id}`)}
|
||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -123,7 +127,7 @@ export default function IssuerDetailPage() {
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
|
||||
<InfoRow label="Name" value={issuer.name} />
|
||||
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
|
||||
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
||||
</div>
|
||||
|
||||
|
||||
+202
-209
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||
@@ -9,83 +9,27 @@ import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Issuer } from '../api/types';
|
||||
import { issuerTypes, typeLabels, getIssuerCatalogStatus, type IssuerTypeConfig } from '../config/issuerTypes';
|
||||
import TypeSelector from '../components/issuer/TypeSelector';
|
||||
import ConfigForm from '../components/issuer/ConfigForm';
|
||||
import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
|
||||
|
||||
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;
|
||||
/** Derive display status from backend enabled boolean */
|
||||
function issuerStatus(issuer: Issuer): string {
|
||||
if (issuer.enabled !== undefined) {
|
||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
// Fallback for legacy data that may have status string
|
||||
return issuer.status || 'Unknown';
|
||||
}
|
||||
|
||||
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 [preselectedType, setPreselectedType] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
@@ -109,12 +53,22 @@ export default function IssuersPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
setPreselectedType(null);
|
||||
},
|
||||
});
|
||||
|
||||
const catalogStatus = useMemo(
|
||||
() => getIssuerCatalogStatus(data?.data || []),
|
||||
[data?.data]
|
||||
);
|
||||
|
||||
// Filter issuers by type
|
||||
const filteredIssuers = useMemo(() => {
|
||||
if (!data?.data) return [];
|
||||
if (!typeFilter) return data.data;
|
||||
return data.data.filter(i => i.type === typeFilter);
|
||||
}, [data?.data, typeFilter]);
|
||||
|
||||
const columns: Column<Issuer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -138,7 +92,7 @@ export default function IssuersPage() {
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (i) => <StatusBadge status={i.status} />,
|
||||
render: (i) => <StatusBadge status={issuerStatus(i)} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
@@ -146,9 +100,15 @@ export default function IssuersPage() {
|
||||
render: (i) => {
|
||||
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">—</span>;
|
||||
return (
|
||||
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
||||
{JSON.stringify(i.config).slice(0, 60)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfigModal({ title: `${i.name} Configuration`, config: i.config });
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
View Config
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -184,14 +144,12 @@ export default function IssuersPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Issuers"
|
||||
subtitle={data ? `${data.total} issuers` : undefined}
|
||||
subtitle={data ? `${data.total} configured` : undefined}
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(null);
|
||||
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"
|
||||
>
|
||||
@@ -205,49 +163,83 @@ export default function IssuersPage() {
|
||||
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
|
||||
<>
|
||||
{/* Issuer Type Catalog Cards */}
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Issuer Types</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{catalogStatus.map(({ type, status, count }) => (
|
||||
<CatalogCard
|
||||
key={type.id}
|
||||
type={type}
|
||||
status={status}
|
||||
count={count}
|
||||
onConfigure={() => {
|
||||
setPreselectedType(type.id);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
onFilter={() => {
|
||||
// Match both the canonical id and aliases
|
||||
const filterValue = type.id === 'local' ? 'local' : type.id;
|
||||
setTypeFilter(prev => prev === filterValue ? '' : filterValue);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configured Issuers Table */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink-muted">Configured Issuers</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{issuerTypes.filter(t => !t.comingSoon).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredIssuers}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={typeFilter ? `No ${typeLabels[typeFilter] || typeFilter} issuers configured` : 'No issuers configured'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Detail Modal */}
|
||||
{configModal && (
|
||||
<ConfigDetailModal
|
||||
title={configModal.title}
|
||||
config={configModal.config}
|
||||
onClose={() => setConfigModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Issuer Modal */}
|
||||
{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 });
|
||||
preselectedType={preselectedType}
|
||||
onSubmit={(name, type, config) => {
|
||||
createMutation.mutate({ name, type, config });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
setPreselectedType(null);
|
||||
}}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
@@ -256,30 +248,94 @@ export default function IssuersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Catalog Card ───────────────────────────────────────────────
|
||||
|
||||
interface CatalogCardProps {
|
||||
type: IssuerTypeConfig;
|
||||
status: 'connected' | 'available' | 'coming_soon';
|
||||
count: number;
|
||||
onConfigure: () => void;
|
||||
onFilter: () => void;
|
||||
}
|
||||
|
||||
function CatalogCard({ type, status, count, onConfigure, onFilter }: CatalogCardProps) {
|
||||
const statusConfig = {
|
||||
connected: { label: `${count} configured`, cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
available: { label: 'Available', cls: 'bg-brand-500/10 text-brand-400 border-brand-500/30' },
|
||||
coming_soon: { label: 'Coming Soon', cls: 'bg-gray-500/10 text-gray-400 border-gray-500/30' },
|
||||
};
|
||||
const { label, cls } = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className={`p-4 border rounded-lg ${status === 'coming_soon' ? 'border-surface-border/50 opacity-60' : 'border-surface-border'}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="font-medium text-ink text-sm">{type.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted mb-3">{type.description}</p>
|
||||
{status === 'connected' && (
|
||||
<button
|
||||
onClick={onFilter}
|
||||
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
View issuers
|
||||
</button>
|
||||
)}
|
||||
{status === 'available' && (
|
||||
<button
|
||||
onClick={onConfigure}
|
||||
className="text-xs px-3 py-1 bg-brand-600 text-white rounded hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Issuer Modal ────────────────────────────────────────
|
||||
|
||||
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;
|
||||
preselectedType: string | null;
|
||||
onSubmit: (name: string, type: string, config: Record<string, unknown>) => 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);
|
||||
function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) {
|
||||
const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(preselectedType);
|
||||
const [form, setForm] = useState<Record<string, unknown>>(() => {
|
||||
if (preselectedType) {
|
||||
const tc = issuerTypes.find(t => t.id === preselectedType);
|
||||
const defaults: Record<string, unknown> = {};
|
||||
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||
return defaults;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const selectedTypeConfig = issuerTypes.find(t => t.id === selectedType);
|
||||
|
||||
function handleTypeSelect(typeId: string) {
|
||||
setSelectedType(typeId);
|
||||
const tc = issuerTypes.find(t => t.id === typeId);
|
||||
const defaults: Record<string, unknown> = {};
|
||||
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||
setForm(defaults);
|
||||
setStep('config');
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedType || !form.name) return;
|
||||
const config = { ...form };
|
||||
const name = config.name as string;
|
||||
delete config.name;
|
||||
onSubmit(name, selectedType, config);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
@@ -289,10 +345,7 @@ function CreateIssuerModal({
|
||||
<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 onClick={onCancel} className="text-ink-muted hover:text-ink transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -300,79 +353,28 @@ function CreateIssuerModal({
|
||||
{/* 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>
|
||||
<TypeSelector onSelect={handleTypeSelect} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Name field always shown */}
|
||||
{/* Name field */}
|
||||
<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)}
|
||||
onChange={(e) => setForm({ ...form, 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>
|
||||
))}
|
||||
{/* Type-specific fields via ConfigForm */}
|
||||
{selectedTypeConfig && (
|
||||
<ConfigForm
|
||||
fields={selectedTypeConfig.configFields}
|
||||
values={form}
|
||||
onChange={(key, value) => setForm({ ...form, [key]: value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,7 +383,7 @@ function CreateIssuerModal({
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
onClick={() => setStep('type')}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Back
|
||||
@@ -395,22 +397,13 @@ function CreateIssuerModal({
|
||||
</button>
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
onClick={handleSubmit}
|
||||
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>
|
||||
|
||||
Reference in New Issue
Block a user