Compare commits

...

2 Commits

Author SHA1 Message Date
shankar0123 6c8d4eca40 feat: frontend audit fixes, README accuracy pass, doc updates
Frontend audit (10 categories): lifecycle fields in types, new API
functions (CRL, OCSP, deployments, updateIssuer/Target, getPolicy),
issuer/owner/profile filters on CertificatesPage, last_renewal_at
column, error_message column on JobsPage, full crypto policy UI on
ProfilesPage (key algorithms, EKUs, SAN patterns), key info + CA
badge on DiscoveryPage, edit modal on TargetDetailPage, tags field
on certificate creation, darwin→macOS mapping on AgentFleetPage.
211 Vitest tests passing.

README accuracy: test counts (1300+ Go, 211 frontend), page count
(24), demo data (32 certs, 7 issuers, 180 days), endpoint count
(97), MCP tools (80), CLI subcommands (10), moved shipped items
out of "Coming in v2.1.0".

Docs: architecture.md diagrams updated (Vault PKI, DigiCert,
Traefik, Caddy added), features.md Vault/DigiCert status updated.
Version bumped to v2.0.20. cli binary removed from git tracking.
Testing guide Part 41 added (12 auto + 9 manual tests).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-30 22:10:45 -04:00
shankar0123 836534f2a7 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>
2026-03-30 18:58:23 -04:00
25 changed files with 1439 additions and 287 deletions
+1
View File
@@ -62,6 +62,7 @@ certctl-agent
certctl-cli certctl-cli
/server /server
/agent /agent
/cli
# Private strategy docs # Private strategy docs
roadmap.md roadmap.md
+17 -14
View File
@@ -43,7 +43,7 @@ timeline
| [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts | | [Migrate from acme.sh](docs/migrate-from-acmesh.md) | Migration guide for acme.sh users with DNS-01 scripts |
| [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure | | [certctl for cert-manager Users](docs/certctl-for-cert-manager-users.md) | Using certctl alongside cert-manager for non-Kubernetes infrastructure |
> **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,471 Go tests + 193 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach. > **Next release:** v2.1.0 will be tagged after the full V2 feature suite passes manual QA across all 34 sections of the [testing guide](docs/testing-guide.md). Automated CI (1,300+ Go tests + 211 frontend tests) gates every commit; the manual playbook covers integration, deployment, and UX verification that unit tests can't reach.
## Why certctl Exists ## Why certctl Exists
@@ -59,8 +59,8 @@ For a detailed comparison with CertKit, KeyTalk, and enterprise platforms (Venaf
certctl gives you a single pane of glass for every TLS certificate in your organization: certctl gives you a single pane of glass for every TLS certificate in your organization:
- **Web dashboard** — 22 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview - **Web dashboard** — 24 operational pages: certificate inventory, deployment timeline with TLS verification, bulk operations (renew/revoke/reassign), discovery triage, network scan management, approval workflows, audit trail with CSV/JSON export, agent fleet overview with OS/arch grouping, short-lived credential monitoring, digest email preview
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters - **REST API** — 97 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
- **Agents** — generate private keys locally (ECDSA P-256), discover existing certs on disk (PEM/DER), submit CSRs only (private keys never leave your servers) - **Agents** — generate private keys locally (ECDSA P-256), discover existing certs on disk (PEM/DER), submit CSRs only (private keys never leave your servers)
- **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents, concurrent scanning with configurable timeouts - **Network scanner** — discovers certificates on TLS endpoints across CIDR ranges without requiring agents, concurrent scanning with configurable timeouts
- **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included - **Certificate export** — PEM (JSON or file download) and PKCS#12 formats, with audit trail; private keys never included
@@ -131,7 +131,7 @@ All connectors are pluggable — build your own by implementing the [connector i
<tr> <tr>
<td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td> <td><a href="docs/screenshots/v2-policies.png"><img src="docs/screenshots/v2-policies.png" width="270" alt="Policies"></a><br><b>Policies</b><br><sub>Ownership, lifetime, renewal rules</sub></td>
<td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td> <td><a href="docs/screenshots/v2-profiles.png"><img src="docs/screenshots/v2-profiles.png" width="270" alt="Profiles"></a><br><b>Profiles</b><br><sub>Key types, max TTL, crypto constraints</sub></td>
<td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca connectors</sub></td> <td><a href="docs/screenshots/v2-issuers.png"><img src="docs/screenshots/v2-issuers.png" width="270" alt="Issuers"></a><br><b>Issuers</b><br><sub>Local CA, ACME, step-ca, Vault PKI, DigiCert</sub></td>
</tr> </tr>
<tr> <tr>
<td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td> <td><a href="docs/screenshots/v2-targets.png"><img src="docs/screenshots/v2-targets.png" width="270" alt="Targets"></a><br><b>Targets</b><br><sub>NGINX, Apache, HAProxy, Traefik, Caddy deployment</sub></td>
@@ -145,7 +145,7 @@ All connectors are pluggable — build your own by implementing the [connector i
</tr> </tr>
</table> </table>
> **22 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (with approval workflow), notifications, policies, profiles, issuers, targets (wizard with NGINX/Apache/HAProxy/Traefik/Caddy/F5/IIS), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, and network scan management. > **24 operational GUI pages** covering the full certificate lifecycle: dashboard, certificates (list + detail with EKU badges, deployment timeline, TLS verification status), agents, fleet overview, jobs (list + detail with approval workflow), notifications, policies, profiles, issuers (catalog + detail), targets (list + detail + wizard), owners, teams, agent groups, audit trail, short-lived credentials, discovery triage, network scan management, digest email preview, and observability metrics.
## Quick Start ## Quick Start
@@ -166,7 +166,7 @@ docker compose -f deploy/docker-compose.yml up -d --build
Wait ~30 seconds, then open **http://localhost:8443** in your browser. Wait ~30 seconds, then open **http://localhost:8443** in your browser.
The dashboard comes pre-loaded with 35 demo certificates across 5 issuers, 8 agents, 90 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months. The dashboard comes pre-loaded with 32 demo certificates across 7 issuers, 8 agents, 180 days of job history, discovery scan data, and network scan targets — a realistic snapshot of a certificate inventory that looks like it's been running for months.
Verify the API: Verify the API:
```bash ```bash
@@ -174,7 +174,7 @@ curl http://localhost:8443/health
# {"status":"healthy"} # {"status":"healthy"}
curl -s http://localhost:8443/api/v1/certificates | jq '.total' curl -s http://localhost:8443/api/v1/certificates | jq '.total'
# 35 # 32
``` ```
### Agent Install (One-Liner) ### Agent Install (One-Liner)
@@ -374,7 +374,7 @@ make docker-clean # Stop + remove volumes
## API Overview ## API Overview
99 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml). 97 endpoints under `/api/v1/` + `/.well-known/est/`, all returning JSON. List endpoints support pagination, sparse field selection (`?fields=`), sort (`?sort=-notAfter`), time-range filters, and cursor-based pagination. Full request/response schemas in the [OpenAPI 3.1 spec](api/openapi.yaml).
### Key Endpoints ### Key Endpoints
``` ```
@@ -451,7 +451,7 @@ certctl-cli certs list --format json # JSON output (default: table)
## MCP Server (AI Integration) ## MCP Server (AI Integration)
certctl ships a standalone MCP (Model Context Protocol) server that exposes all 78 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client. certctl ships a standalone MCP (Model Context Protocol) server that exposes all 80 API endpoints as tools for AI assistants — Claude, Cursor, Windsurf, OpenClaw, VS Code Copilot, and any MCP-compatible client.
```bash ```bash
# Install # Install
@@ -487,7 +487,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
### V2: Operational Maturity ### V2: Operational Maturity
30 milestones complete, 1500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability. 30+ milestones complete, 1,500+ tests. See the [Feature Inventory](docs/features.md) for details on every capability.
**What shipped (all ✅):** **What shipped (all ✅):**
@@ -499,7 +499,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
- **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging - **Observability** — Prometheus + JSON metrics, 5 stats API endpoints, dashboard charts (heatmap, trends, distribution), agent fleet overview, structured logging
- **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding - **EST Server** (RFC 7030) — device/WiFi certificate enrollment, PKCS#7 wire format, configurable issuer + profile binding
- **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client - **MCP Server** — 78 API operations as AI tools for Claude, Cursor, and any MCP-compatible client
- **CLI** — 12 subcommands (list/get/renew/revoke certs, agents, jobs, import, status), JSON/table output - **CLI** — 10 subcommands (list/get/renew/revoke certs, list agents/jobs, import, status, health, metrics), JSON/table output
- **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors - **Notifications** — Email (SMTP), Webhooks, Slack, Microsoft Teams, PagerDuty, OpsGenie connectors
- **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging - **API Enhancements** — sparse fields, sort, time-range filters, cursor pagination, immutable API audit logging
- **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides - **Compliance Mapping** — SOC 2 Type II, PCI-DSS 4.0, NIST SP 800-57 alignment guides
@@ -512,14 +512,17 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
- **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP - **Scheduled Certificate Digest** — HTML email digests with certificate stats, expiration timeline, job trends, and agent health; configurable daily/hourly/weekly briefings via SMTP
- **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress - **Helm Chart** — Production-ready Kubernetes with server Deployment, PostgreSQL StatefulSet with PVC, Agent DaemonSet, security contexts, resource limits, optional Ingress
**Coming in v2.1.0:** **Also shipped:**
- Dynamic issuer and target configuration via GUI (no env var restarts)
- Issuer catalog page (see all supported CAs, configure from dashboard) - Issuer catalog page (see all supported CAs, configure from dashboard)
- First-run onboarding wizard - Vault PKI and DigiCert CertCentral issuer connectors (Beta)
- Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer) - Turnkey deployment examples (ACME+NGINX, wildcard+DNS-01, private CA+Traefik, step-ca+HAProxy, multi-issuer)
- Migration guides (Certbot, acme.sh, cert-manager complement) - Migration guides (Certbot, acme.sh, cert-manager complement)
- One-line agent install script with cross-compiled binaries - One-line agent install script with cross-compiled binaries
**Coming in v2.1.0:**
- Dynamic issuer and target configuration via GUI (no env var restarts)
- First-run onboarding wizard
### V3: certctl Pro ### V3: certctl Pro
Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views. Team access controls, identity provider integration, enterprise deployment targets, compliance and risk scoring, advanced fleet operations, event-driven architecture, advanced search, real-time operational views.
BIN
View File
Binary file not shown.
+7 -3
View File
@@ -80,13 +80,16 @@ flowchart TB
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"] CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
CA3["step-ca\n(/sign API)"] CA3["step-ca\n(/sign API)"]
CA4["OpenSSL / Custom CA\n(script-based)"] CA4["OpenSSL / Custom CA\n(script-based)"]
CA6["Vault PKI\n(planned)"] CA6["Vault PKI\n(token auth, /sign API)"]
CA7["DigiCert CertCentral\n(async order model)"]
end end
subgraph "Target Systems" subgraph "Target Systems"
T1["NGINX\n(file write + reload)"] T1["NGINX\n(file write + reload)"]
T4["Apache httpd\n(file write + reload)"] T4["Apache httpd\n(file write + reload)"]
T5["HAProxy\n(combined PEM + reload)"] T5["HAProxy\n(combined PEM + reload)"]
T6["Traefik\n(file provider)"]
T7["Caddy\n(admin API / file)"]
T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"] T2["F5 BIG-IP\n(proxy agent + iControl REST, planned)"]
T3["IIS\n(agent-local PowerShell, planned)"] T3["IIS\n(agent-local PowerShell, planned)"]
end end
@@ -96,7 +99,7 @@ flowchart TB
SVC --> REPO SVC --> REPO
REPO --> PG REPO --> PG
SCHED --> SVC SCHED --> SVC
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7
A1 & A2 & A3 -->|"CSR + Heartbeat"| API A1 & A2 & A3 -->|"CSR + Heartbeat"| API
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3 API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
@@ -506,7 +509,8 @@ flowchart TB
II --> ACME["ACME v2"] II --> ACME["ACME v2"]
II --> SC["step-ca"] II --> SC["step-ca"]
II --> OC["OpenSSL / Custom CA"] II --> OC["OpenSSL / Custom CA"]
II --> VP["Vault PKI (planned)"] II --> VP["Vault PKI"]
II --> DC["DigiCert CertCentral"]
end end
subgraph "Target Connectors" subgraph "Target Connectors"
+2 -2
View File
@@ -1469,8 +1469,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) | | **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
| **Certificate health scores** | ✗ | ✓ | Planned V3 | | **Certificate health scores** | ✗ | ✓ | Planned V3 |
| **Compliance scoring** | ✗ | ✓ | Planned V3 | | **Compliance scoring** | ✗ | ✓ | Planned V3 |
| **DigiCert issuer** | ✗ | ✓ | Planned V2.1 (free) | | **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
| **Vault PKI issuer** | ✗ | ✓ | Planned V2.1 (free) | | **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
--- ---
+234 -3
View File
@@ -44,6 +44,8 @@ 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 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 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37) - [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37)
- [Part 40: Issuer Catalog Page (M33)](#part-40-issuer-catalog-page-m33)
- [Part 41: Frontend Audit Fixes](#part-41-frontend-audit-fixes)
- [Release Sign-Off](#release-sign-off) - [Release Sign-Off](#release-sign-off)
--- ---
@@ -5372,6 +5374,189 @@ 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.
---
## Part 41: Frontend Audit Fixes
Comprehensive frontend coverage audit closed 60 gaps between backend capabilities and GUI surfaces. This part validates the critical fixes.
### Automated Tests (qa-smoke-test.sh Part 41)
| # | Test | Assertion |
|---|------|-----------|
| 41.1 | Certificate TS type has lifecycle fields | `types.ts` contains `last_renewal_at`, `last_deployment_at`, `target_ids` |
| 41.2 | API client has new endpoint functions | `client.ts` exports `updateIssuer`, `updateTarget`, `getCertificateDeployments`, `getCRL`, `getOCSPStatus`, `getPolicy` |
| 41.3 | CertificatesPage has filter dropdowns | Contains `issuerFilter`, `ownerFilter`, `profileFilter` state vars |
| 41.4 | CertificatesPage shows last_renewal_at | Column renders `last_renewal_at` field |
| 41.5 | JobsPage shows error_message | Error column displays first 80 chars for failed jobs |
| 41.6 | ProfilesPage has key algorithm fields | Create form includes `allowed_key_algorithms` with add/remove rows |
| 41.7 | ProfilesPage has EKU checkboxes | Create form includes `allowed_ekus` checkbox group |
| 41.8 | DiscoveryPage shows is_ca badge | CA badge renders for discovered CA certificates |
| 41.9 | TargetDetailPage has Edit functionality | Edit button wired to `updateTarget` API call |
| 41.10 | CertificatesPage has tags field | Create form includes tags input (key=value pairs) |
| 41.11 | AgentFleetPage maps darwin to macOS | OS display mapping applied to pie chart and platform headers |
| 41.12 | Frontend builds after audit fixes | `npm run build` succeeds |
### Manual Tests
**41.M1: Profile Create Form — Key Algorithm Configuration**
1. Navigate to Profiles page, click "+ New Profile"
2. Verify default algorithms shown: ECDSA 256+, RSA 2048+
3. Click "Remove" on RSA row — verify it disappears
4. Click "+ Add" — verify Ed25519 appears (with "fixed" instead of size dropdown)
5. Submit form, verify profile created with correct `allowed_key_algorithms` array
**PASS if** algorithms are configurable and persisted correctly.
**41.M2: Profile Create Form — EKU Selection**
1. In Create Profile modal, verify EKU checkboxes visible (serverAuth checked by default)
2. Check "Email Protection (S/MIME)" and "Client Authentication"
3. Submit, verify profile has `allowed_ekus: ["serverAuth", "emailProtection", "clientAuth"]`
**PASS if** EKUs are selectable and sent to backend.
**41.M3: Certificate Create Form — Tags**
1. Navigate to Certificates page, click "+ New Certificate"
2. Enter tags: `env=prod, team=platform, app=api`
3. Submit, verify certificate created with `tags: {"env": "prod", "team": "platform", "app": "api"}`
**PASS if** tags are parsed and persisted as key-value pairs.
**41.M4: Jobs Table — Error Message Column**
1. Navigate to Jobs page, filter to "Failed" status
2. Verify "Error" column shows truncated error message (max 80 chars with "...")
3. Hover over truncated message, verify full text in tooltip
**PASS if** error messages visible for failed jobs.
**41.M5: Certificates Table — Lifecycle Columns**
1. Navigate to Certificates page
2. Verify "Last Renewal" and "Last Deploy" columns visible
3. Verify dates shown for certs with data, "—" for certs without
**PASS if** lifecycle timestamps displayed.
**41.M6: Certificate Filters — Issuer/Owner/Profile Dropdowns**
1. Navigate to Certificates page
2. Verify Issuer, Owner, Profile dropdown filters visible
3. Select an issuer — verify table filters to matching certificates
4. Clear filter, select a profile — verify filtering works
**PASS if** all three filter dropdowns functional.
**41.M7: Target Detail — Edit Button**
1. Navigate to a target detail page
2. Click "Edit" button
3. Modify name, click "Save"
4. Verify name updated on the page
**PASS if** target edit persists via API.
**41.M8: Discovery Table — CA Badge**
1. Navigate to Discovery page
2. Verify "Key" column shows algorithm + key size
3. For CA certificates, verify purple "CA" badge displayed
**PASS if** CA certificates visually distinguished.
**41.M9: Fleet Overview — macOS Display**
1. Navigate to Fleet Overview page
2. Verify OS pie chart shows "macOS" instead of "darwin"
3. Verify platform section headers show "macOS / amd64" (not "darwin / amd64")
**PASS if** darwin correctly mapped to macOS in all locations.
---
## Release Sign-Off ## 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**). 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 +6137,60 @@ These must be green before starting manual QA:
| 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox | | 39.4 | Async poll behavior | Manual | ☐ | | Requires DigiCert sandbox |
| 39.5 | Revocation records locally | 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 | ☐ | | |
### Part 41: Frontend Audit Fixes
| Test | Description | Method | Pass? | Date | Notes |
|------|-------------|--------|-------|------|-------|
| 41.s1 | Certificate TS type has lifecycle fields | Auto | ☐ | | qa-smoke-test.sh 41.1 |
| 41.s2 | API client has new endpoint functions | Auto | ☐ | | qa-smoke-test.sh 41.2 |
| 41.s3 | CertificatesPage has filter dropdowns | Auto | ☐ | | qa-smoke-test.sh 41.3 |
| 41.s4 | CertificatesPage shows last_renewal_at | Auto | ☐ | | qa-smoke-test.sh 41.4 |
| 41.s5 | JobsPage shows error_message | Auto | ☐ | | qa-smoke-test.sh 41.5 |
| 41.s6 | ProfilesPage has key algorithm fields | Auto | ☐ | | qa-smoke-test.sh 41.6 |
| 41.s7 | ProfilesPage has EKU checkboxes | Auto | ☐ | | qa-smoke-test.sh 41.7 |
| 41.s8 | DiscoveryPage shows is_ca badge | Auto | ☐ | | qa-smoke-test.sh 41.8 |
| 41.s9 | TargetDetailPage has Edit functionality | Auto | ☐ | | qa-smoke-test.sh 41.9 |
| 41.s10 | CertificatesPage has tags field | Auto | ☐ | | qa-smoke-test.sh 41.10 |
| 41.s11 | AgentFleetPage maps darwin to macOS | Auto | ☐ | | qa-smoke-test.sh 41.11 |
| 41.s12 | Frontend builds after audit fixes | Auto | ☐ | | qa-smoke-test.sh 41.12 |
| 41.m1 | Profile create form — key algorithm config | Manual | ☐ | | |
| 41.m2 | Profile create form — EKU selection | Manual | ☐ | | |
| 41.m3 | Certificate create form — tags | Manual | ☐ | | |
| 41.m4 | Jobs table — error message column | Manual | ☐ | | |
| 41.m5 | Certificates table — lifecycle columns | Manual | ☐ | | |
| 41.m6 | Certificate filters — issuer/owner/profile | Manual | ☐ | | |
| 41.m7 | Target detail — edit button | Manual | ☐ | | |
| 41.m8 | Discovery table — CA badge | Manual | ☐ | | |
| 41.m9 | Fleet overview — macOS display | Manual | ☐ | | |
### Summary ### Summary
| Category | Count | | Category | Count |
|----------|-------| |----------|-------|
| ☑ Auto (passed in `qa-smoke-test.sh`) | 136 | | ☑ Auto (passed in `qa-smoke-test.sh`) | 144 |
| ☐ Auto (not yet run) | 12 |
| — Skipped (preconditions not met in demo) | 5 | | — Skipped (preconditions not met in demo) | 5 |
| ☐ Manual (requires hands-on verification) | 226 | | ☐ Manual (requires hands-on verification) | 241 |
| **Total** | **367** | | **Total** | **402** |
**Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss. **Automated tests must also be green.** CI passing is necessary but not sufficient — this manual QA catches integration issues that isolated unit tests miss.
+1
View File
@@ -243,6 +243,7 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
created, err := h.svc.CreateCertificate(cert) created, err := h.svc.CreateCertificate(cert)
if err != nil { 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) ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
return return
} }
+8
View File
@@ -304,6 +304,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
if cert.UpdatedAt.IsZero() { if cert.UpdatedAt.IsZero() {
cert.UpdatedAt = now 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 { if err := s.certRepo.Create(context.Background(), &cert); err != nil {
return nil, fmt.Errorf("failed to create certificate: %w", err) return nil, fmt.Errorf("failed to create certificate: %w", err)
} }
+99
View File
@@ -83,6 +83,12 @@ import {
getIssuer, getIssuer,
getTarget, getTarget,
getPrometheusMetrics, getPrometheusMetrics,
getCertificateDeployments,
getCRL,
getOCSPStatus,
updateIssuer,
updateTarget,
getPolicy,
} from './client'; } from './client';
// Mock global fetch // Mock global fetch
@@ -632,6 +638,50 @@ describe('API Client', () => {
expect(url).toBe('/api/v1/issuers'); expect(url).toBe('/api/v1/issuers');
expect(init.method).toBe('POST'); 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 ────────────────────────────────────────── // ─── Audit ──────────────────────────────────────────
@@ -1106,4 +1156,53 @@ describe('API Client', () => {
expect(init.headers['Authorization']).toBe('Bearer prom-key'); expect(init.headers['Authorization']).toBe('Bearer prom-key');
}); });
}); });
describe('Frontend Audit: New API Functions', () => {
it('getCertificateDeployments sends GET with cert ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ data: [], total: 0 }));
await getCertificateDeployments('mc-1');
expect(mockFetch.mock.calls[0][0]).toContain('/api/v1/certificates/mc-1/deployments');
});
it('getCRL sends GET to /crl', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ entries: [], total: 0 }));
await getCRL();
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/crl');
});
it('getOCSPStatus sends GET with issuer and serial', async () => {
const buf = new ArrayBuffer(8);
mockFetch.mockReturnValueOnce(
Promise.resolve({
ok: true,
status: 200,
arrayBuffer: () => Promise.resolve(buf),
} as Response)
);
await getOCSPStatus('iss-local', 'ABC123');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/ocsp/iss-local/ABC123');
});
it('updateIssuer sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-1', name: 'Updated' }));
await updateIssuer('iss-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/issuers/iss-1');
expect(init.method).toBe('PUT');
});
it('updateTarget sends PUT with data', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 't-1', name: 'Updated' }));
await updateTarget('t-1', { name: 'Updated' });
const [url, init] = mockFetch.mock.calls[0];
expect(url).toBe('/api/v1/targets/t-1');
expect(init.method).toBe('PUT');
});
it('getPolicy sends GET with policy ID', async () => {
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'pol-1', name: 'Test' }));
await getPolicy('pol-1');
expect(mockFetch.mock.calls[0][0]).toBe('/api/v1/policies/pol-1');
});
});
}); });
+29
View File
@@ -122,6 +122,26 @@ export const exportCertificatePKCS12 = (id: string, password: string = '') => {
}); });
}; };
// Certificate Deployments
export const getCertificateDeployments = (id: string, params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
return fetchJSON<PaginatedResponse<Job>>(`${BASE}/certificates/${id}/deployments?${qs}`);
};
// CRL / OCSP
export const getCRL = () =>
fetchJSON<{ version: number; entries: unknown[]; total: number; generated_at: string }>(`${BASE}/crl`);
export const getOCSPStatus = (issuerId: string, serial: string) => {
const headers: Record<string, string> = {};
if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
return fetch(`${BASE}/ocsp/${issuerId}/${serial}`, { headers })
.then(r => {
if (!r.ok) throw new Error(`OCSP request failed: ${r.status}`);
return r.arrayBuffer();
});
};
// Agents // Agents
export const getAgents = (params: Record<string, string> = {}) => { export const getAgents = (params: Record<string, string> = {}) => {
const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString();
@@ -170,6 +190,9 @@ export const createPolicy = (data: Partial<PolicyRule>) =>
export const updatePolicy = (id: string, data: Partial<PolicyRule>) => export const updatePolicy = (id: string, data: Partial<PolicyRule>) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) }); fetchJSON<PolicyRule>(`${BASE}/policies/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const getPolicy = (id: string) =>
fetchJSON<PolicyRule>(`${BASE}/policies/${id}`);
export const deletePolicy = (id: string) => export const deletePolicy = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' }); fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
@@ -188,6 +211,9 @@ export const createIssuer = (data: Partial<Issuer>) =>
export const testIssuerConnection = (id: string) => export const testIssuerConnection = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' }); fetchJSON<{ message: string }>(`${BASE}/issuers/${id}/test`, { method: 'POST' });
export const updateIssuer = (id: string, data: Partial<Issuer>) =>
fetchJSON<Issuer>(`${BASE}/issuers/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteIssuer = (id: string) => export const deleteIssuer = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' }); fetchJSON<{ message: string }>(`${BASE}/issuers/${id}`, { method: 'DELETE' });
@@ -200,6 +226,9 @@ export const getTargets = (params: Record<string, string> = {}) => {
export const createTarget = (data: Partial<Target>) => export const createTarget = (data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) }); fetchJSON<Target>(`${BASE}/targets`, { method: 'POST', body: JSON.stringify(data) });
export const updateTarget = (id: string, data: Partial<Target>) =>
fetchJSON<Target>(`${BASE}/targets/${id}`, { method: 'PUT', body: JSON.stringify(data) });
export const deleteTarget = (id: string) => export const deleteTarget = (id: string) =>
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' }); fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
+9
View File
@@ -18,7 +18,10 @@ export interface Certificate {
expires_at: string; expires_at: string;
revoked_at?: string; revoked_at?: string;
revocation_reason?: string; revocation_reason?: string;
target_ids?: string[];
tags: Record<string, string>; tags: Record<string, string>;
last_renewal_at?: string;
last_deployment_at?: string;
created_at: string; created_at: string;
updated_at: string; updated_at: string;
} }
@@ -45,6 +48,8 @@ export interface CertificateVersion {
csr_pem: string; csr_pem: string;
not_before: string; not_before: string;
not_after: string; not_after: string;
key_algorithm?: string;
key_size?: number;
created_at: string; created_at: string;
} }
@@ -135,7 +140,10 @@ export interface Issuer {
type: string; type: string;
config: Record<string, unknown>; config: Record<string, unknown>;
status: string; status: string;
/** Backend returns enabled boolean; status is derived from this */
enabled: boolean;
created_at: string; created_at: string;
updated_at?: string;
} }
export interface Target { export interface Target {
@@ -147,6 +155,7 @@ export interface Target {
config: Record<string, unknown>; config: Record<string, unknown>;
status: string; status: string;
created_at: string; created_at: string;
updated_at?: string;
} }
export interface KeyAlgorithmRule { export interface KeyAlgorithmRule {
+1 -1
View File
@@ -71,7 +71,7 @@ export default function Layout() {
</nav> </nav>
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between"> <div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.14</span> <span className="text-[10px] text-brand-300/60 font-mono">v2.0.20</span>
{authRequired && ( {authRequired && (
<button <button
onClick={logout} onClick={logout}
+3
View File
@@ -23,6 +23,9 @@ const statusStyles: Record<string, string> = {
Unmanaged: 'badge-warning', Unmanaged: 'badge-warning',
Managed: 'badge-success', Managed: 'badge-success',
Dismissed: 'badge-neutral', Dismissed: 'badge-neutral',
// Issuer statuses
Enabled: 'badge-success',
Disabled: 'badge-neutral',
// Notification statuses // Notification statuses
sent: 'badge-success', sent: 'badge-success',
pending: 'badge-warning', 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>
);
}
+139
View File
@@ -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>
);
}
+179
View File
@@ -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,
};
});
}
+10 -2
View File
@@ -13,6 +13,14 @@ const OS_COLORS: Record<string, string> = {
unknown: '#64748b', unknown: '#64748b',
}; };
const OS_DISPLAY_NAMES: Record<string, string> = {
darwin: 'macOS',
};
function displayOS(os: string): string {
return OS_DISPLAY_NAMES[os.toLowerCase()] || os;
}
const STATUS_COLORS: Record<string, string> = { const STATUS_COLORS: Record<string, string> = {
Online: '#10b981', Online: '#10b981',
Offline: '#ef4444', Offline: '#ef4444',
@@ -86,7 +94,7 @@ export default function AgentFleetPage() {
return acc; return acc;
}, {}); }, {});
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({ const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
name, name: displayOS(name),
value, value,
fill: OS_COLORS[name.toLowerCase()] || '#64748b', fill: OS_COLORS[name.toLowerCase()] || '#64748b',
})); }));
@@ -216,7 +224,7 @@ export default function AgentFleetPage() {
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }} style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
/> />
<h4 className="text-sm font-medium text-ink"> <h4 className="text-sm font-medium text-ink">
{group.os} / {group.arch} {displayOS(group.os)} / {group.arch}
</h4> </h4>
<span className="text-xs text-ink-faint"> <span className="text-xs text-ink-faint">
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''} {group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
+142 -22
View File
@@ -1,7 +1,7 @@
import { useState } from 'react'; import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners } from '../api/client'; import { getCertificates, createCertificate, triggerRenewal, revokeCertificate, updateCertificate, getOwners, getProfiles, getIssuers } from '../api/client';
import { REVOCATION_REASONS } from '../api/types'; import { REVOCATION_REASONS } from '../api/types';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
@@ -16,20 +16,66 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
name: '', name: '',
id: '', id: '',
common_name: '', common_name: '',
sans: '',
environment: 'production', environment: 'production',
issuer_id: '', issuer_id: '',
certificate_profile_id: '',
owner_id: '', owner_id: '',
team_id: '', team_id: '',
renewal_policy_id: '', renewal_policy_id: '',
tags: '',
}); });
const [error, setError] = useState(''); const [error, setError] = useState('');
const { data: profilesResp } = useQuery({
queryKey: ['profiles'],
queryFn: () => getProfiles(),
});
const { data: issuersResp } = useQuery({
queryKey: ['issuers'],
queryFn: () => getIssuers(),
});
const profiles = profilesResp?.data || [];
const issuers = issuersResp?.data || [];
const selectedProfile = profiles.find(p => p.id === form.certificate_profile_id);
const ttlLabel = selectedProfile
? selectedProfile.max_ttl_seconds < 3600
? `${Math.round(selectedProfile.max_ttl_seconds / 60)}m`
: selectedProfile.max_ttl_seconds < 86400
? `${Math.round(selectedProfile.max_ttl_seconds / 3600)}h`
: `${Math.round(selectedProfile.max_ttl_seconds / 86400)}d`
: null;
const mutation = useMutation({ const mutation = useMutation({
mutationFn: () => createCertificate(form), mutationFn: () => {
const payload: Record<string, unknown> = { ...form };
// Convert comma-separated SANs to array
if (form.sans.trim()) {
payload.sans = form.sans.split(',').map(s => s.trim()).filter(Boolean);
} else {
delete payload.sans;
}
// Convert comma-separated key=value tags to object
if (form.tags.trim()) {
const tags: Record<string, string> = {};
form.tags.split(',').forEach(pair => {
const [k, ...v] = pair.split('=');
if (k?.trim()) tags[k.trim()] = v.join('=').trim();
});
payload.tags = tags;
} else {
delete payload.tags;
}
return createCertificate(payload);
},
onSuccess: () => onSuccess(), onSuccess: () => onSuccess(),
onError: (err: Error) => setError(err.message), onError: (err: Error) => setError(err.message),
}); });
const inputClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400 focus:ring-1 focus:ring-brand-400/20";
const selectClass = "w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink";
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}> <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-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}> <div className="bg-surface border border-surface-border rounded p-6 w-full max-w-lg shadow-xl" onClick={e => e.stopPropagation()}>
@@ -39,57 +85,90 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Name *</label> <label className="text-xs text-ink-muted block mb-1">Name *</label>
<input value={form.name} onChange={e => setForm(f => ({ ...f, name: e.target.value }))} <input value={form.name} onChange={e => setForm(f => ({ ...f, name: 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 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="API Production Cert" /> placeholder="API Production Cert" />
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">ID (optional)</label> <label className="text-xs text-ink-muted block mb-1">ID (optional)</label>
<input value={form.id} onChange={e => setForm(f => ({ ...f, id: e.target.value }))} <input value={form.id} onChange={e => setForm(f => ({ ...f, id: 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 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="mc-api-prod (auto-generated if empty)" /> placeholder="mc-api-prod (auto-generated if empty)" />
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Common Name *</label> <label className="text-xs text-ink-muted block mb-1">Common Name *</label>
<input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: e.target.value }))} <input value={form.common_name} onChange={e => setForm(f => ({ ...f, common_name: 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 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="api.example.com" /> placeholder="api.example.com" />
</div> </div>
<div>
<label className="text-xs text-ink-muted block mb-1">SANs (comma-separated)</label>
<input value={form.sans} onChange={e => setForm(f => ({ ...f, sans: e.target.value }))}
className={inputClass}
placeholder="api.example.com, api-v2.example.com" />
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="text-xs text-ink-muted block mb-1">Issuer *</label>
<select value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))}
className={selectClass}>
<option value="">Select issuer...</option>
{issuers.map(i => (
<option key={i.id} value={i.id}>{i.name}</option>
))}
</select>
</div>
<div>
<label className="text-xs text-ink-muted block mb-1">
Profile {ttlLabel && <span className="text-brand-400 font-medium">(TTL: {ttlLabel})</span>}
</label>
<select value={form.certificate_profile_id} onChange={e => setForm(f => ({ ...f, certificate_profile_id: e.target.value }))}
className={selectClass}>
<option value="">Select profile...</option>
{profiles.map(p => (
<option key={p.id} value={p.id}>
{p.name}{p.max_ttl_seconds ? ` (${p.max_ttl_seconds < 3600 ? `${Math.round(p.max_ttl_seconds / 60)}m` : p.max_ttl_seconds < 86400 ? `${Math.round(p.max_ttl_seconds / 3600)}h` : `${Math.round(p.max_ttl_seconds / 86400)}d`})` : ''}
</option>
))}
</select>
</div>
</div>
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Environment</label> <label className="text-xs text-ink-muted block mb-1">Environment</label>
<select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))} <select value={form.environment} onChange={e => setForm(f => ({ ...f, environment: e.target.value }))}
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink"> className={selectClass}>
<option value="production">Production</option> <option value="production">Production</option>
<option value="staging">Staging</option> <option value="staging">Staging</option>
<option value="development">Development</option> <option value="development">Development</option>
</select> </select>
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label> <label className="text-xs text-ink-muted block mb-1">Policy</label>
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_id: e.target.value }))} <input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: 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 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="iss-local" /> placeholder="rp-standard" />
</div> </div>
</div> </div>
<div className="grid grid-cols-3 gap-3"> <div className="grid grid-cols-2 gap-3">
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Owner ID</label> <label className="text-xs text-ink-muted block mb-1">Owner</label>
<input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: e.target.value }))} <input value={form.owner_id} onChange={e => setForm(f => ({ ...f, owner_id: 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 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="o-alice" /> placeholder="o-alice" />
</div> </div>
<div> <div>
<label className="text-xs text-ink-muted block mb-1">Team ID</label> <label className="text-xs text-ink-muted block mb-1">Team</label>
<input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: e.target.value }))} <input value={form.team_id} onChange={e => setForm(f => ({ ...f, team_id: 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 focus:ring-1 focus:ring-brand-400/20" className={inputClass}
placeholder="t-platform" /> placeholder="t-platform" />
</div> </div>
<div> </div>
<label className="text-xs text-ink-muted block mb-1">Policy ID</label> <div>
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))} <label className="text-xs text-ink-muted block mb-1">Tags</label>
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 focus:ring-1 focus:ring-brand-400/20" <input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
placeholder="rp-standard" /> className={inputClass}
</div> placeholder="env=prod, team=platform, app=api" />
<p className="text-xs text-ink-faint mt-0.5">Comma-separated key=value pairs</p>
</div> </div>
</div> </div>
<div className="flex justify-end gap-3 mt-6"> <div className="flex justify-end gap-3 mt-6">
@@ -245,15 +324,25 @@ export default function CertificatesPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [statusFilter, setStatusFilter] = useState(''); const [statusFilter, setStatusFilter] = useState('');
const [envFilter, setEnvFilter] = useState(''); const [envFilter, setEnvFilter] = useState('');
const [issuerFilter, setIssuerFilter] = useState('');
const [ownerFilter, setOwnerFilter] = useState('');
const [profileFilter, setProfileFilter] = useState('');
const [showCreate, setShowCreate] = useState(false); const [showCreate, setShowCreate] = useState(false);
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set()); const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
const [showBulkRevoke, setShowBulkRevoke] = useState(false); const [showBulkRevoke, setShowBulkRevoke] = useState(false);
const [showBulkReassign, setShowBulkReassign] = useState(false); const [showBulkReassign, setShowBulkReassign] = useState(false);
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null); const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (statusFilter) params.status = statusFilter; if (statusFilter) params.status = statusFilter;
if (envFilter) params.environment = envFilter; if (envFilter) params.environment = envFilter;
if (issuerFilter) params.issuer_id = issuerFilter;
if (ownerFilter) params.owner_id = ownerFilter;
if (profileFilter) params.profile_id = profileFilter;
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['certificates', params], queryKey: ['certificates', params],
@@ -302,7 +391,8 @@ export default function CertificatesPage() {
); );
}, },
}, },
{ key: 'env', label: 'Environment', render: (c) => <span className="text-ink-muted">{c.environment || '—'}</span> }, { key: 'last_renewal', label: 'Last Renewal', render: (c) => <span className="text-xs text-ink-muted">{c.last_renewal_at ? formatDate(c.last_renewal_at) : '—'}</span> },
{ key: 'last_deploy', label: 'Last Deploy', render: (c) => <span className="text-xs text-ink-muted">{c.last_deployment_at ? formatDate(c.last_deployment_at) : '—'}</span> },
{ key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> }, { key: 'issuer', label: 'Issuer', render: (c) => <span className="text-ink-muted text-xs">{c.issuer_id}</span> },
{ key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> }, { key: 'owner', label: 'Owner', render: (c) => <span className="text-ink-muted text-xs">{c.owner_id}</span> },
]; ];
@@ -382,6 +472,36 @@ export default function CertificatesPage() {
<option value="staging">Staging</option> <option value="staging">Staging</option>
<option value="development">Development</option> <option value="development">Development</option>
</select> </select>
<select
value={issuerFilter}
onChange={e => setIssuerFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All issuers</option>
{issuersData?.data?.map(i => (
<option key={i.id} value={i.id}>{i.name}</option>
))}
</select>
<select
value={ownerFilter}
onChange={e => setOwnerFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All owners</option>
{ownersData?.data?.map(o => (
<option key={o.id} value={o.id}>{o.name}</option>
))}
</select>
<select
value={profileFilter}
onChange={e => setProfileFilter(e.target.value)}
className="bg-white border border-surface-border rounded px-3 py-1.5 text-sm text-ink"
>
<option value="">All profiles</option>
{profilesData?.data?.map(p => (
<option key={p.id} value={p.id}>{p.name}</option>
))}
</select>
</div> </div>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{error ? ( {error ? (
+12
View File
@@ -197,6 +197,18 @@ export default function DiscoveryPage() {
label: 'Expiry', label: 'Expiry',
render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>, render: (c) => <span className="text-xs">{formatExpiry(c.not_after)}</span>,
}, },
{
key: 'key_info',
label: 'Key',
render: (c) => (
<div className="flex items-center gap-1">
<span className="text-xs text-ink-muted">{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}</span>
{c.is_ca && (
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">CA</span>
)}
</div>
),
},
{ {
key: 'fingerprint', key: 'fingerprint',
label: 'Fingerprint', label: 'Fingerprint',
+29 -25
View File
@@ -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 { useQuery, useMutation } from '@tanstack/react-query';
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client'; import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
@@ -7,15 +7,8 @@ import DataTable from '../components/DataTable';
import type { Column } from '../components/DataTable'; import type { Column } from '../components/DataTable';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { Certificate } from '../api/types'; import type { Certificate, Issuer } from '../api/types';
import { typeLabels, redactConfig } from '../config/issuerTypes';
const typeLabels: Record<string, string> = {
local_ca: 'Local CA',
acme: 'ACME (Let\'s Encrypt)',
step_ca: 'step-ca',
openssl: 'OpenSSL / Custom',
vault: 'Vault PKI',
};
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return ( 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() { export default function IssuerDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
const { data: issuer, isLoading, error, refetch } = useQuery({ const { data: issuer, isLoading, error, refetch } = useQuery({
queryKey: ['issuer', id], queryKey: ['issuer', id],
@@ -65,13 +67,7 @@ export default function IssuerDetailPage() {
); );
} }
// Redact sensitive config fields const safeConfig = issuer.config ? redactConfig(issuer.config) : {};
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 certColumns: Column<Certificate>[] = [ const certColumns: Column<Certificate>[] = [
{ {
@@ -94,13 +90,21 @@ export default function IssuerDetailPage() {
title={issuer.name} title={issuer.name}
subtitle={typeLabels[issuer.type] || issuer.type} subtitle={typeLabels[issuer.type] || issuer.type}
action={ action={
<button <div className="flex gap-2">
onClick={() => testMutation.mutate()} <button
disabled={testMutation.isPending} onClick={() => navigate(`/issuers?edit=${issuer.id}`)}
className="btn btn-primary text-xs disabled:opacity-50" className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
> >
{testMutation.isPending ? 'Testing...' : 'Test Connection'} Edit
</button> </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="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
<InfoRow label="Name" value={issuer.name} /> <InfoRow label="Name" value={issuer.name} />
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} /> <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)} /> <InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
</div> </div>
+202 -209
View File
@@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useMemo } from 'react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client'; import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
@@ -9,83 +9,27 @@ import StatusBadge from '../components/StatusBadge';
import ErrorState from '../components/ErrorState'; import ErrorState from '../components/ErrorState';
import { formatDateTime } from '../api/utils'; import { formatDateTime } from '../api/utils';
import type { Issuer } from '../api/types'; 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> = { /** Derive display status from backend enabled boolean */
local_ca: 'Local CA', function issuerStatus(issuer: Issuer): string {
acme: 'ACME', if (issuer.enabled !== undefined) {
stepca: 'step-ca', return issuer.enabled ? 'Enabled' : 'Disabled';
openssl: 'OpenSSL/Custom', }
vault: 'Vault PKI', // Fallback for legacy data that may have status string
manual: 'Manual', return issuer.status || 'Unknown';
};
interface IssuerConfigField {
key: string;
label: string;
placeholder?: string;
required: boolean;
type?: string;
options?: string[];
defaultValue?: string;
} }
interface IssuerTypeConfig {
id: string;
name: string;
description: string;
configFields: IssuerConfigField[];
}
const issuerTypes: IssuerTypeConfig[] = [
{
id: 'local_ca',
name: 'Local CA',
description: 'Self-signed or subordinate CA for certificate issuance',
configFields: [
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false },
],
},
{
id: 'acme',
name: 'ACME',
description: "Let's Encrypt or other ACME-compatible CA",
configFields: [
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
],
},
{
id: 'stepca',
name: 'step-ca',
description: 'Smallstep private CA',
configFields: [
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true },
],
},
{
id: 'openssl',
name: 'OpenSSL/Custom',
description: 'Script-based signing with your own CA',
configFields: [
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
],
},
];
export default function IssuersPage() { export default function IssuersPage() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null); const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [createStep, setCreateStep] = useState<'type' | 'config'>('type'); const [preselectedType, setPreselectedType] = useState<string | null>(null);
const [selectedType, setSelectedType] = useState<string | null>(null); const [typeFilter, setTypeFilter] = useState<string>('');
const [createForm, setCreateForm] = useState<Record<string, unknown>>({}); const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
const { data, isLoading, error, refetch } = useQuery({ const { data, isLoading, error, refetch } = useQuery({
queryKey: ['issuers'], queryKey: ['issuers'],
@@ -109,12 +53,22 @@ export default function IssuersPage() {
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['issuers'] }); queryClient.invalidateQueries({ queryKey: ['issuers'] });
setShowCreateModal(false); setShowCreateModal(false);
setCreateStep('type'); setPreselectedType(null);
setSelectedType(null);
setCreateForm({});
}, },
}); });
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>[] = [ const columns: Column<Issuer>[] = [
{ {
key: 'name', key: 'name',
@@ -138,7 +92,7 @@ export default function IssuersPage() {
{ {
key: 'status', key: 'status',
label: 'Status', label: 'Status',
render: (i) => <StatusBadge status={i.status} />, render: (i) => <StatusBadge status={issuerStatus(i)} />,
}, },
{ {
key: 'config', key: 'config',
@@ -146,9 +100,15 @@ export default function IssuersPage() {
render: (i) => { render: (i) => {
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">&mdash;</span>; if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">&mdash;</span>;
return ( return (
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block"> <button
{JSON.stringify(i.config).slice(0, 60)} onClick={(e) => {
</span> 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 <PageHeader
title="Issuers" title="Issuers"
subtitle={data ? `${data.total} issuers` : undefined} subtitle={data ? `${data.total} configured` : undefined}
action={ action={
<button <button
onClick={() => { onClick={() => {
setPreselectedType(null);
setShowCreateModal(true); 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" 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> <button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
</div> </div>
)} )}
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{error ? ( {error ? (
<ErrorState error={error as Error} onRetry={() => refetch()} /> <ErrorState error={error as Error} onRetry={() => refetch()} />
) : ( ) : (
<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> </div>
{/* Config Detail Modal */}
{configModal && (
<ConfigDetailModal
title={configModal.title}
config={configModal.config}
onClose={() => setConfigModal(null)}
/>
)}
{/* Create Issuer Modal */}
{showCreateModal && ( {showCreateModal && (
<CreateIssuerModal <CreateIssuerModal
step={createStep} preselectedType={preselectedType}
selectedType={selectedType} onSubmit={(name, type, config) => {
form={createForm} createMutation.mutate({ name, type, config });
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={() => { onCancel={() => {
setShowCreateModal(false); setShowCreateModal(false);
setCreateStep('type'); setPreselectedType(null);
setSelectedType(null);
setCreateForm({});
}} }}
isSubmitting={createMutation.isPending} 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 { interface CreateIssuerModalProps {
step: 'type' | 'config'; preselectedType: string | null;
selectedType: string | null; onSubmit: (name: string, type: string, config: Record<string, unknown>) => void;
form: Record<string, unknown>;
onTypeSelect: (type: string) => void;
onFormChange: (field: string, value: unknown) => void;
onBack: () => void;
onSubmit: () => void;
onCancel: () => void; onCancel: () => void;
isSubmitting: boolean; isSubmitting: boolean;
} }
function CreateIssuerModal({ function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) {
step, const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type');
selectedType, const [selectedType, setSelectedType] = useState<string | null>(preselectedType);
form, const [form, setForm] = useState<Record<string, unknown>>(() => {
onTypeSelect, if (preselectedType) {
onFormChange, const tc = issuerTypes.find(t => t.id === preselectedType);
onBack, const defaults: Record<string, unknown> = {};
onSubmit, tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
onCancel, return defaults;
isSubmitting, }
}: CreateIssuerModalProps) { return {};
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType); });
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 ( return (
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center"> <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"> <h2 className="text-lg font-semibold text-ink">
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`} {step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
</h2> </h2>
<button <button onClick={onCancel} className="text-ink-muted hover:text-ink transition-colors">
onClick={onCancel}
className="text-ink-muted hover:text-ink transition-colors"
>
</button> </button>
</div> </div>
@@ -300,79 +353,28 @@ function CreateIssuerModal({
{/* Content */} {/* Content */}
<div className="px-6 py-6"> <div className="px-6 py-6">
{step === 'type' ? ( {step === 'type' ? (
<div className="grid grid-cols-2 gap-4"> <TypeSelector onSelect={handleTypeSelect} />
{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"> <div className="space-y-5">
{/* Name field always shown */} {/* Name field */}
<div> <div>
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label> <label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
<input <input
type="text" type="text"
value={(form.name as string) || ''} 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" 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" 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>
{/* Type-specific fields via ConfigForm */}
{/* Type-specific fields */} {selectedTypeConfig && (
{selectedTypeConfig?.configFields.map((field) => ( <ConfigForm
<div key={field.key}> fields={selectedTypeConfig.configFields}
<label className="block text-sm font-medium text-ink mb-2"> values={form}
{field.label} onChange={(key, value) => setForm({ ...form, [key]: value })}
{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>
)} )}
</div> </div>
@@ -381,7 +383,7 @@ function CreateIssuerModal({
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3"> <div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
{step === 'config' && ( {step === 'config' && (
<button <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" className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
> >
Back Back
@@ -395,22 +397,13 @@ function CreateIssuerModal({
</button> </button>
{step === 'config' && ( {step === 'config' && (
<button <button
onClick={onSubmit} onClick={handleSubmit}
disabled={isSubmitting || !form.name} 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" 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'} {isSubmitting ? 'Creating...' : 'Create Issuer'}
</button> </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> </div>
</div> </div>
+9
View File
@@ -136,6 +136,15 @@ export default function JobsPage() {
label: 'Attempts', label: 'Attempts',
render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>, render: (j) => <span className="text-ink-muted">{j.attempts}/{j.max_attempts}</span>,
}, },
{
key: 'error',
label: 'Error',
render: (j) => j.status === 'Failed' && j.error_message ? (
<span className="text-xs text-red-600 truncate max-w-[200px] inline-block" title={j.error_message}>
{j.error_message.length > 80 ? j.error_message.substring(0, 80) + '...' : j.error_message}
</span>
) : <span className="text-xs text-ink-faint"></span>,
},
{ key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> }, { key: 'scheduled', label: 'Scheduled', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.scheduled_at)}</span> },
{ key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> }, { key: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
{ {
+158 -4
View File
@@ -25,11 +25,63 @@ interface CreateProfileModalProps {
error: string | null; error: string | null;
} }
const AVAILABLE_ALGORITHMS = ['RSA', 'ECDSA', 'Ed25519'];
const ALGORITHM_MIN_SIZES: Record<string, number[]> = {
RSA: [2048, 3072, 4096],
ECDSA: [256, 384],
Ed25519: [0],
};
const AVAILABLE_EKUS = [
{ value: 'serverAuth', label: 'Server Authentication (TLS)' },
{ value: 'clientAuth', label: 'Client Authentication' },
{ value: 'codeSigning', label: 'Code Signing' },
{ value: 'emailProtection', label: 'Email Protection (S/MIME)' },
{ value: 'timeStamping', label: 'Time Stamping' },
];
interface KeyAlgorithmEntry {
algorithm: string;
min_size: number;
}
function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) { function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: CreateProfileModalProps) {
const [name, setName] = useState(''); const [name, setName] = useState('');
const [description, setDescription] = useState(''); const [description, setDescription] = useState('');
const [ttl, setTtl] = useState('86400'); const [ttl, setTtl] = useState('86400');
const [shortLived, setShortLived] = useState(false); const [shortLived, setShortLived] = useState(false);
const [keyAlgorithms, setKeyAlgorithms] = useState<KeyAlgorithmEntry[]>([
{ algorithm: 'ECDSA', min_size: 256 },
{ algorithm: 'RSA', min_size: 2048 },
]);
const [selectedEkus, setSelectedEkus] = useState<string[]>(['serverAuth']);
const [sanPatterns, setSanPatterns] = useState('');
const [spiffePattern, setSpiffePattern] = useState('');
const addAlgorithm = () => {
const unused = AVAILABLE_ALGORITHMS.find(a => !keyAlgorithms.some(ka => ka.algorithm === a));
if (unused) {
setKeyAlgorithms([...keyAlgorithms, { algorithm: unused, min_size: ALGORITHM_MIN_SIZES[unused][0] }]);
}
};
const removeAlgorithm = (idx: number) => {
setKeyAlgorithms(keyAlgorithms.filter((_, i) => i !== idx));
};
const updateAlgorithm = (idx: number, field: 'algorithm' | 'min_size', value: string | number) => {
const updated = [...keyAlgorithms];
if (field === 'algorithm') {
updated[idx] = { algorithm: value as string, min_size: ALGORITHM_MIN_SIZES[value as string]?.[0] || 0 };
} else {
updated[idx] = { ...updated[idx], min_size: value as number };
}
setKeyAlgorithms(updated);
};
const toggleEku = (eku: string) => {
setSelectedEkus(prev => prev.includes(eku) ? prev.filter(e => e !== eku) : [...prev, eku]);
};
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
@@ -39,20 +91,31 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
description: description.trim(), description: description.trim(),
max_ttl_seconds: parseInt(ttl) || 86400, max_ttl_seconds: parseInt(ttl) || 86400,
allow_short_lived: shortLived, allow_short_lived: shortLived,
allowed_key_algorithms: keyAlgorithms,
allowed_ekus: selectedEkus,
required_san_patterns: sanPatterns.trim() ? sanPatterns.split(',').map(s => s.trim()).filter(Boolean) : [],
spiffe_uri_pattern: spiffePattern.trim() || '',
enabled: true, enabled: true,
}); });
setName(''); setName('');
setDescription(''); setDescription('');
setTtl('86400'); setTtl('86400');
setShortLived(false); setShortLived(false);
setKeyAlgorithms([{ algorithm: 'ECDSA', min_size: 256 }, { algorithm: 'RSA', min_size: 2048 }]);
setSelectedEkus(['serverAuth']);
setSanPatterns('');
setSpiffePattern('');
onSuccess(); onSuccess();
}; };
if (!isOpen) return null; if (!isOpen) return null;
const inputClass = 'w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
const selectClass = 'bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400';
return ( return (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={onClose}> <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()}> <div className="bg-surface border border-surface-border rounded p-5 w-full max-w-lg shadow-xl max-h-[90vh] overflow-y-auto" onClick={e => e.stopPropagation()}>
<h2 className="text-lg font-semibold text-ink mb-4">Create Profile</h2> <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>} {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"> <form onSubmit={handleSubmit} className="space-y-4">
@@ -61,7 +124,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
<input <input
value={name} value={name}
onChange={e => setName(e.target.value)} 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" className={inputClass}
placeholder="e.g., Web Server Certs" placeholder="e.g., Web Server Certs"
required required
/> />
@@ -71,7 +134,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
<textarea <textarea
value={description} value={description}
onChange={e => setDescription(e.target.value)} 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" className={inputClass}
placeholder="Optional description" placeholder="Optional description"
rows={2} rows={2}
/> />
@@ -82,7 +145,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
type="number" type="number"
value={ttl} value={ttl}
onChange={e => setTtl(e.target.value)} 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" className={inputClass}
placeholder="86400" placeholder="86400"
/> />
<p className="text-xs text-ink-muted mt-1"> <p className="text-xs text-ink-muted mt-1">
@@ -109,6 +172,97 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
/> />
<label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label> <label htmlFor="shortLived" className="text-sm text-ink">Allow short-lived certs</label>
</div> </div>
{/* Allowed Key Algorithms */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-ink">Allowed Key Algorithms</label>
{keyAlgorithms.length < AVAILABLE_ALGORITHMS.length && (
<button type="button" onClick={addAlgorithm} className="text-xs text-brand-600 hover:text-brand-700 font-medium">
+ Add
</button>
)}
</div>
<div className="space-y-2">
{keyAlgorithms.map((ka, idx) => (
<div key={idx} className="flex items-center gap-2">
<select
value={ka.algorithm}
onChange={e => updateAlgorithm(idx, 'algorithm', e.target.value)}
className={selectClass + ' flex-1'}
>
{AVAILABLE_ALGORITHMS.map(a => (
<option key={a} value={a} disabled={a !== ka.algorithm && keyAlgorithms.some(k => k.algorithm === a)}>
{a}
</option>
))}
</select>
{ka.algorithm !== 'Ed25519' ? (
<select
value={ka.min_size}
onChange={e => updateAlgorithm(idx, 'min_size', parseInt(e.target.value))}
className={selectClass + ' w-24'}
>
{(ALGORITHM_MIN_SIZES[ka.algorithm] || []).map(s => (
<option key={s} value={s}>{s}+</option>
))}
</select>
) : (
<span className="text-xs text-ink-muted w-24 text-center">fixed</span>
)}
<button type="button" onClick={() => removeAlgorithm(idx)} className="text-xs text-red-500 hover:text-red-600">
Remove
</button>
</div>
))}
{keyAlgorithms.length === 0 && (
<p className="text-xs text-ink-faint">No algorithms configured. Click + Add to allow key types.</p>
)}
</div>
</div>
{/* Allowed EKUs */}
<div>
<label className="block text-sm font-medium text-ink mb-1">Allowed Extended Key Usages</label>
<div className="space-y-1.5">
{AVAILABLE_EKUS.map(eku => (
<label key={eku.value} className="flex items-center gap-2 cursor-pointer">
<input
type="checkbox"
checked={selectedEkus.includes(eku.value)}
onChange={() => toggleEku(eku.value)}
className="w-4 h-4"
/>
<span className="text-sm text-ink">{eku.label}</span>
</label>
))}
</div>
</div>
{/* Required SAN Patterns */}
<div>
<label className="block text-sm font-medium text-ink mb-1">Required SAN Patterns</label>
<input
value={sanPatterns}
onChange={e => setSanPatterns(e.target.value)}
className={inputClass}
placeholder="e.g., *.example.com, api.internal"
/>
<p className="text-xs text-ink-muted mt-1">Comma-separated patterns. Leave empty for no constraints.</p>
</div>
{/* SPIFFE URI Pattern */}
<div>
<label className="block text-sm font-medium text-ink mb-1">SPIFFE URI Pattern</label>
<input
value={spiffePattern}
onChange={e => setSpiffePattern(e.target.value)}
className={inputClass}
placeholder="e.g., spiffe://example.org/service/*"
/>
<p className="text-xs text-ink-muted mt-1">Optional workload identity URI SAN pattern.</p>
</div>
<div className="flex gap-2 pt-4"> <div className="flex gap-2 pt-4">
<button <button
type="submit" type="submit"
+57 -2
View File
@@ -1,6 +1,7 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom'; import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { getTarget, getJobs } from '../api/client'; import { getTarget, getJobs, updateTarget } from '../api/client';
import PageHeader from '../components/PageHeader'; import PageHeader from '../components/PageHeader';
import StatusBadge from '../components/StatusBadge'; import StatusBadge from '../components/StatusBadge';
import DataTable from '../components/DataTable'; import DataTable from '../components/DataTable';
@@ -30,6 +31,18 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
export default function TargetDetailPage() { export default function TargetDetailPage() {
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
const queryClient = useQueryClient();
const [isEditing, setIsEditing] = useState(false);
const [editName, setEditName] = useState('');
const [editHostname, setEditHostname] = useState('');
const updateMutation = useMutation({
mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['target', id] });
setIsEditing(false);
},
});
const { data: target, isLoading, error, refetch } = useQuery({ const { data: target, isLoading, error, refetch } = useQuery({
queryKey: ['target', id], queryKey: ['target', id],
@@ -112,6 +125,18 @@ export default function TargetDetailPage() {
<PageHeader <PageHeader
title={target.name} title={target.name}
subtitle={typeLabels[target.type] || target.type} subtitle={typeLabels[target.type] || target.type}
action={
<button
onClick={() => {
setEditName(target.name);
setEditHostname(target.hostname || '');
setIsEditing(true);
}}
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>
}
/> />
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-6"> <div className="flex-1 overflow-y-auto px-6 py-4 space-y-6">
@@ -164,6 +189,36 @@ export default function TargetDetailPage() {
/> />
</div> </div>
</div> </div>
{/* Edit Modal */}
{isEditing && (
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50" onClick={() => setIsEditing(false)}>
<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">Edit Target</h2>
{updateMutation.isError && (
<div className="mb-4 p-3 bg-red-50 border border-red-200 rounded text-sm text-red-700">
{(updateMutation.error as Error).message}
</div>
)}
<form onSubmit={e => { e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4">
<div>
<label className="block text-sm font-medium text-ink mb-1">Name</label>
<input value={editName} onChange={e => setEditName(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" />
</div>
<div>
<label className="block text-sm font-medium text-ink mb-1">Hostname</label>
<input value={editHostname} onChange={e => setEditHostname(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" />
</div>
<div className="flex gap-2 pt-2">
<button type="submit" disabled={updateMutation.isPending} className="flex-1 btn btn-primary disabled:opacity-50">
{updateMutation.isPending ? 'Saving...' : 'Save'}
</button>
<button type="button" onClick={() => setIsEditing(false)} className="flex-1 btn btn-ghost">Cancel</button>
</div>
</form>
</div>
</div>
)}
</> </>
); );
} }