mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-09 22:28:55 +00:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c8d4eca40 | |||
| 836534f2a7 |
@@ -62,6 +62,7 @@ certctl-agent
|
||||
certctl-cli
|
||||
/server
|
||||
/agent
|
||||
/cli
|
||||
|
||||
# Private strategy docs
|
||||
roadmap.md
|
||||
|
||||
@@ -43,7 +43,7 @@ timeline
|
||||
| [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 |
|
||||
|
||||
> **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
|
||||
|
||||
@@ -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:
|
||||
|
||||
- **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
|
||||
- **REST API** — 99 endpoints under `/api/v1/` + `/.well-known/est/` for complete automation, with sparse fields, sort, cursor pagination, and time-range filters
|
||||
- **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** — 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)
|
||||
- **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
|
||||
@@ -131,7 +131,7 @@ All connectors are pluggable — build your own by implementing the [connector i
|
||||
<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-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>
|
||||
<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>
|
||||
</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
|
||||
|
||||
@@ -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.
|
||||
|
||||
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:
|
||||
```bash
|
||||
@@ -174,7 +174,7 @@ curl http://localhost:8443/health
|
||||
# {"status":"healthy"}
|
||||
|
||||
curl -s http://localhost:8443/api/v1/certificates | jq '.total'
|
||||
# 35
|
||||
# 32
|
||||
```
|
||||
|
||||
### Agent Install (One-Liner)
|
||||
@@ -374,7 +374,7 @@ make docker-clean # Stop + remove volumes
|
||||
|
||||
## 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
|
||||
```
|
||||
@@ -451,7 +451,7 @@ certctl-cli certs list --format json # JSON output (default: table)
|
||||
|
||||
## 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
|
||||
# Install
|
||||
@@ -487,7 +487,7 @@ Core lifecycle management — Local CA + ACME v2 issuers, NGINX target connector
|
||||
|
||||
### 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 ✅):**
|
||||
|
||||
@@ -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
|
||||
- **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
|
||||
- **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
|
||||
- **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
|
||||
@@ -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
|
||||
- **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:**
|
||||
- Dynamic issuer and target configuration via GUI (no env var restarts)
|
||||
**Also shipped:**
|
||||
- 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)
|
||||
- Migration guides (Certbot, acme.sh, cert-manager complement)
|
||||
- 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
|
||||
|
||||
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.
|
||||
|
||||
@@ -80,13 +80,16 @@ flowchart TB
|
||||
CA2["ACME\n(HTTP-01 + DNS-01 + DNS-PERSIST-01)\n(EAB, ZeroSSL auto-EAB)"]
|
||||
CA3["step-ca\n(/sign API)"]
|
||||
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
|
||||
|
||||
subgraph "Target Systems"
|
||||
T1["NGINX\n(file write + reload)"]
|
||||
T4["Apache httpd\n(file write + 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)"]
|
||||
T3["IIS\n(agent-local PowerShell, planned)"]
|
||||
end
|
||||
@@ -96,7 +99,7 @@ flowchart TB
|
||||
SVC --> REPO
|
||||
REPO --> PG
|
||||
SCHED --> SVC
|
||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3
|
||||
SVC -->|"Issue/Renew"| CA1 & CA2 & CA3 & CA4 & CA6 & CA7
|
||||
|
||||
A1 & A2 & A3 -->|"CSR + Heartbeat"| API
|
||||
API -->|"Cert + Chain\n(NO private key)"| A1 & A2 & A3
|
||||
@@ -506,7 +509,8 @@ flowchart TB
|
||||
II --> ACME["ACME v2"]
|
||||
II --> SC["step-ca"]
|
||||
II --> OC["OpenSSL / Custom CA"]
|
||||
II --> VP["Vault PKI (planned)"]
|
||||
II --> VP["Vault PKI"]
|
||||
II --> DC["DigiCert CertCentral"]
|
||||
end
|
||||
|
||||
subgraph "Target Connectors"
|
||||
|
||||
+2
-2
@@ -1469,8 +1469,8 @@ Each guide includes an evidence summary table mapping specific criteria to certc
|
||||
| **Bulk revocation** | ✗ | ✓ | Planned V3 (paid) |
|
||||
| **Certificate health scores** | ✗ | ✓ | Planned V3 |
|
||||
| **Compliance scoring** | ✗ | ✓ | Planned V3 |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
||||
| **Vault PKI issuer** | ✗ | ✓ | Planned V2.1 (free) |
|
||||
| **DigiCert issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||
| **Vault PKI issuer** | ✗ | ✓ | Implemented (Beta) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
+234
-3
@@ -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 38: Vault PKI Connector (M32)](#part-38-vault-pki-connector-m32)
|
||||
- [Part 39: DigiCert Connector (M37)](#part-39-digicert-connector-m37)
|
||||
- [Part 40: Issuer Catalog Page (M33)](#part-40-issuer-catalog-page-m33)
|
||||
- [Part 41: Frontend Audit Fixes](#part-41-frontend-audit-fixes)
|
||||
- [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
|
||||
|
||||
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.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
|
||||
|
||||
| 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 |
|
||||
| ☐ Manual (requires hands-on verification) | 226 |
|
||||
| **Total** | **367** |
|
||||
| ☐ Manual (requires hands-on verification) | 241 |
|
||||
| **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.
|
||||
|
||||
|
||||
@@ -243,6 +243,7 @@ func (h CertificateHandler) CreateCertificate(w http.ResponseWriter, r *http.Req
|
||||
|
||||
created, err := h.svc.CreateCertificate(cert)
|
||||
if err != nil {
|
||||
slog.Error("failed to create certificate", "error", err, "request_id", requestID, "common_name", cert.CommonName, "name", cert.Name)
|
||||
ErrorWithRequestID(w, http.StatusInternalServerError, "Failed to create certificate", requestID)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -304,6 +304,14 @@ func (s *CertificateService) CreateCertificate(cert domain.ManagedCertificate) (
|
||||
if cert.UpdatedAt.IsZero() {
|
||||
cert.UpdatedAt = now
|
||||
}
|
||||
// Default status to Pending if not set (DB column DEFAULT only applies when column is omitted from INSERT)
|
||||
if cert.Status == "" {
|
||||
cert.Status = domain.CertificateStatusPending
|
||||
}
|
||||
// Default tags to empty map if nil (avoids JSON null in JSONB column)
|
||||
if cert.Tags == nil {
|
||||
cert.Tags = make(map[string]string)
|
||||
}
|
||||
if err := s.certRepo.Create(context.Background(), &cert); err != nil {
|
||||
return nil, fmt.Errorf("failed to create certificate: %w", err)
|
||||
}
|
||||
|
||||
@@ -83,6 +83,12 @@ import {
|
||||
getIssuer,
|
||||
getTarget,
|
||||
getPrometheusMetrics,
|
||||
getCertificateDeployments,
|
||||
getCRL,
|
||||
getOCSPStatus,
|
||||
updateIssuer,
|
||||
updateTarget,
|
||||
getPolicy,
|
||||
} from './client';
|
||||
|
||||
// Mock global fetch
|
||||
@@ -632,6 +638,50 @@ describe('API Client', () => {
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
});
|
||||
|
||||
it('createIssuer sends correct payload for VaultPKI type', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-vault', name: 'Vault PKI' }));
|
||||
const vaultPayload = {
|
||||
name: 'Vault PKI',
|
||||
type: 'VaultPKI',
|
||||
config: {
|
||||
addr: 'https://vault.internal:8200',
|
||||
token: 'hvs.test-token',
|
||||
mount: 'pki',
|
||||
role: 'web-certs',
|
||||
ttl: '8760h',
|
||||
},
|
||||
};
|
||||
await createIssuer(vaultPayload);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.type).toBe('VaultPKI');
|
||||
expect(body.config.addr).toBe('https://vault.internal:8200');
|
||||
expect(body.config.role).toBe('web-certs');
|
||||
});
|
||||
|
||||
it('createIssuer sends correct payload for DigiCert type', async () => {
|
||||
mockFetch.mockReturnValueOnce(mockJsonResponse({ id: 'iss-digicert', name: 'DigiCert' }));
|
||||
const digicertPayload = {
|
||||
name: 'DigiCert CertCentral',
|
||||
type: 'DigiCert',
|
||||
config: {
|
||||
api_key: 'test-api-key',
|
||||
org_id: '12345',
|
||||
product_type: 'ssl_basic',
|
||||
},
|
||||
};
|
||||
await createIssuer(digicertPayload);
|
||||
const [url, init] = mockFetch.mock.calls[0];
|
||||
expect(url).toBe('/api/v1/issuers');
|
||||
expect(init.method).toBe('POST');
|
||||
const body = JSON.parse(init.body);
|
||||
expect(body.type).toBe('DigiCert');
|
||||
expect(body.config.org_id).toBe('12345');
|
||||
expect(body.config.product_type).toBe('ssl_basic');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Audit ──────────────────────────────────────────
|
||||
@@ -1106,4 +1156,53 @@ describe('API Client', () => {
|
||||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
|
||||
export const getAgents = (params: Record<string, string> = {}) => {
|
||||
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>) =>
|
||||
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) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/policies/${id}`, { method: 'DELETE' });
|
||||
|
||||
@@ -188,6 +211,9 @@ export const createIssuer = (data: Partial<Issuer>) =>
|
||||
export const testIssuerConnection = (id: string) =>
|
||||
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) =>
|
||||
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>) =>
|
||||
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) =>
|
||||
fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' });
|
||||
|
||||
|
||||
@@ -18,7 +18,10 @@ export interface Certificate {
|
||||
expires_at: string;
|
||||
revoked_at?: string;
|
||||
revocation_reason?: string;
|
||||
target_ids?: string[];
|
||||
tags: Record<string, string>;
|
||||
last_renewal_at?: string;
|
||||
last_deployment_at?: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -45,6 +48,8 @@ export interface CertificateVersion {
|
||||
csr_pem: string;
|
||||
not_before: string;
|
||||
not_after: string;
|
||||
key_algorithm?: string;
|
||||
key_size?: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
@@ -135,7 +140,10 @@ export interface Issuer {
|
||||
type: string;
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
/** Backend returns enabled boolean; status is derived from this */
|
||||
enabled: boolean;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface Target {
|
||||
@@ -147,6 +155,7 @@ export interface Target {
|
||||
config: Record<string, unknown>;
|
||||
status: string;
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface KeyAlgorithmRule {
|
||||
|
||||
@@ -71,7 +71,7 @@ export default function Layout() {
|
||||
</nav>
|
||||
|
||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.14</span>
|
||||
<span className="text-[10px] text-brand-300/60 font-mono">v2.0.20</span>
|
||||
{authRequired && (
|
||||
<button
|
||||
onClick={logout}
|
||||
|
||||
@@ -23,6 +23,9 @@ const statusStyles: Record<string, string> = {
|
||||
Unmanaged: 'badge-warning',
|
||||
Managed: 'badge-success',
|
||||
Dismissed: 'badge-neutral',
|
||||
// Issuer statuses
|
||||
Enabled: 'badge-success',
|
||||
Disabled: 'badge-neutral',
|
||||
// Notification statuses
|
||||
sent: 'badge-success',
|
||||
pending: 'badge-warning',
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Full config viewer modal with sensitive field redaction.
|
||||
* Replaces the 60-char truncation in the issuers table.
|
||||
* Reusable for targets in M35 — no IssuersPage-specific imports.
|
||||
*/
|
||||
import { isSensitiveKey } from '../../config/issuerTypes';
|
||||
|
||||
interface ConfigDetailModalProps {
|
||||
title: string;
|
||||
config: Record<string, unknown>;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export default function ConfigDetailModal({ title, config, onClose }: ConfigDetailModalProps) {
|
||||
const entries = Object.entries(config);
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
<div className="bg-surface border border-surface-border rounded-lg shadow-lg max-w-lg w-full mx-4">
|
||||
<div className="border-b border-surface-border px-6 py-4 flex justify-between items-center">
|
||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||
<button onClick={onClose} className="text-ink-muted hover:text-ink transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-6 py-4 max-h-96 overflow-y-auto">
|
||||
{entries.length === 0 ? (
|
||||
<div className="text-sm text-ink-faint py-4 text-center">No configuration data</div>
|
||||
) : (
|
||||
<div className="space-y-0">
|
||||
{entries.map(([key, val]) => {
|
||||
const redacted = isSensitiveKey(key);
|
||||
return (
|
||||
<div key={key} className="flex justify-between py-2 border-b border-surface-border/50">
|
||||
<span className="text-sm text-ink-muted">{key}</span>
|
||||
<span className="text-sm text-ink font-mono text-right max-w-xs break-all">
|
||||
{redacted ? '********' : String(val ?? '')}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
/**
|
||||
* Renders config fields from an IssuerTypeConfig.configFields definition.
|
||||
* Handles sensitive field masking. M34 will reuse this directly for its
|
||||
* dynamic config wizard. M35 can reuse it for target config forms.
|
||||
*/
|
||||
import type { ConfigField } from '../../config/issuerTypes';
|
||||
|
||||
interface ConfigFormProps {
|
||||
fields: ConfigField[];
|
||||
values: Record<string, unknown>;
|
||||
onChange: (key: string, value: unknown) => void;
|
||||
/** When true, sensitive fields show as ******** with a "Change" button.
|
||||
* Used in edit mode — empty value means "keep existing". */
|
||||
editMode?: boolean;
|
||||
}
|
||||
|
||||
export default function ConfigForm({ fields, values, onChange, editMode }: ConfigFormProps) {
|
||||
return (
|
||||
<div className="space-y-5">
|
||||
{fields.map((field) => (
|
||||
<ConfigFieldInput
|
||||
key={field.key}
|
||||
field={field}
|
||||
value={values[field.key]}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
editMode={editMode}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ConfigFieldInput({
|
||||
field,
|
||||
value,
|
||||
onChange,
|
||||
editMode,
|
||||
}: {
|
||||
field: ConfigField;
|
||||
value: unknown;
|
||||
onChange: (v: unknown) => void;
|
||||
editMode?: boolean;
|
||||
}) {
|
||||
const inputCls =
|
||||
'w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors';
|
||||
|
||||
// In edit mode, sensitive fields that haven't been touched show as masked
|
||||
if (editMode && field.sensitive && value === undefined) {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-ink-muted font-mono">********</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onChange('')}
|
||||
className="text-xs text-brand-400 hover:text-brand-500"
|
||||
>
|
||||
Change
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'select') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<select
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className={inputCls}
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>{opt}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'textarea') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<textarea
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
className={`${inputCls} font-mono text-xs`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<input
|
||||
type="number"
|
||||
value={(value as number | string) ?? ''}
|
||||
onChange={(e) => onChange(e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
placeholder={field.placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// text or password
|
||||
return (
|
||||
<div>
|
||||
<FieldLabel field={field} />
|
||||
<input
|
||||
type={field.type === 'password' ? 'password' : 'text'}
|
||||
value={(value as string) || ''}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className={inputCls}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FieldLabel({ field }: { field: ConfigField }) {
|
||||
return (
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
{field.sensitive && (
|
||||
<span className="ml-2 text-xs text-yellow-500 font-normal">sensitive</span>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Issuer type selector grid. Used in both the catalog view and create wizard.
|
||||
* M34 will reuse this for its 3-step wizard (Select Type step).
|
||||
*/
|
||||
import { issuerTypes, type IssuerTypeConfig } from '../../config/issuerTypes';
|
||||
|
||||
interface TypeSelectorProps {
|
||||
onSelect: (typeId: string) => void;
|
||||
/** Filter to only show these type IDs. If not provided, shows all non-comingSoon types. */
|
||||
filterIds?: string[];
|
||||
}
|
||||
|
||||
export default function TypeSelector({ onSelect, filterIds }: TypeSelectorProps) {
|
||||
const types = filterIds
|
||||
? issuerTypes.filter(t => filterIds.includes(t.id))
|
||||
: issuerTypes.filter(t => !t.comingSoon);
|
||||
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{types.map((type: IssuerTypeConfig) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onSelect(type.id)}
|
||||
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="font-medium text-ink">{type.name}</span>
|
||||
</div>
|
||||
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
/**
|
||||
* Shared issuer type configuration.
|
||||
* Imported by IssuersPage.tsx (M33), and will be reused by M34 (Dynamic Issuer Config)
|
||||
* for its 3-step wizard config forms.
|
||||
*/
|
||||
|
||||
export interface ConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
type?: 'text' | 'password' | 'number' | 'select' | 'textarea';
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
/** Mark fields that contain secrets (tokens, keys, passwords).
|
||||
* Display as ******** when viewing existing config. M34 will use this
|
||||
* for AES-GCM encryption decisions. */
|
||||
sensitive?: boolean;
|
||||
}
|
||||
|
||||
export interface IssuerTypeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
icon: string;
|
||||
configFields: ConfigField[];
|
||||
/** If true, this type is not yet implemented — show as "Coming Soon" */
|
||||
comingSoon?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Canonical type label map. Keys match what the backend API returns.
|
||||
* DB stores: local, acme, stepca, openssl, VaultPKI, DigiCert
|
||||
*/
|
||||
export const typeLabels: Record<string, string> = {
|
||||
local: 'Local CA',
|
||||
local_ca: 'Local CA', // backward compat (some frontend references)
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
VaultPKI: 'Vault PKI',
|
||||
DigiCert: 'DigiCert',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
/**
|
||||
* All supported issuer types + 2 "Coming Soon" stubs.
|
||||
* Order: most common first, coming-soon last.
|
||||
*/
|
||||
export const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt, ZeroSSL, or any ACME-compatible CA",
|
||||
icon: '\uD83D\uDD12',
|
||||
configFields: [
|
||||
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
|
||||
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
|
||||
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
|
||||
{ key: 'eab_kid', label: 'EAB Key ID', placeholder: 'External Account Binding Key ID (optional)', required: false },
|
||||
{ key: 'eab_hmac', label: 'EAB HMAC Key', placeholder: 'External Account Binding HMAC key', required: false, type: 'password', sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'local',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for internal certificates',
|
||||
icon: '\uD83C\uDFE0',
|
||||
configFields: [
|
||||
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
|
||||
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA with JWK provisioner auth',
|
||||
icon: '\uD83D\uDC63',
|
||||
configFields: [
|
||||
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
|
||||
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
|
||||
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true, sensitive: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'VaultPKI',
|
||||
name: 'Vault PKI',
|
||||
description: 'HashiCorp Vault PKI secrets engine',
|
||||
icon: '\uD83D\uDD10',
|
||||
configFields: [
|
||||
{ key: 'addr', label: 'Vault Address', placeholder: 'https://vault.internal:8200', required: true },
|
||||
{ key: 'token', label: 'Vault Token', placeholder: 'hvs.CAES...', required: true, type: 'password', sensitive: true },
|
||||
{ key: 'mount', label: 'PKI Mount Path', placeholder: 'pki', required: false, defaultValue: 'pki' },
|
||||
{ key: 'role', label: 'PKI Role Name', placeholder: 'web-certs', required: true },
|
||||
{ key: 'ttl', label: 'Certificate TTL', placeholder: '8760h', required: false, defaultValue: '8760h' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'DigiCert',
|
||||
name: 'DigiCert CertCentral',
|
||||
description: 'DigiCert CertCentral for OV/EV certificates',
|
||||
icon: '\uD83C\uDF10',
|
||||
configFields: [
|
||||
{ key: 'api_key', label: 'DigiCert API Key', placeholder: 'Your DigiCert API key', required: true, type: 'password', sensitive: true },
|
||||
{ key: 'org_id', label: 'Organization ID', placeholder: '12345', required: true },
|
||||
{ key: 'product_type', label: 'Product Type', type: 'select', options: ['ssl_basic', 'ssl_plus', 'ssl_wildcard', 'ssl_ev_basic', 'ssl_ev_plus'], required: false, defaultValue: 'ssl_basic' },
|
||||
{ key: 'base_url', label: 'API Base URL Override', placeholder: 'https://www.digicert.com/services/v2', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
icon: '\uD83D\uDD27',
|
||||
configFields: [
|
||||
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
|
||||
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
|
||||
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
|
||||
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'sectigo',
|
||||
name: 'Sectigo',
|
||||
description: 'Sectigo Certificate Manager \u2014 coming soon',
|
||||
icon: '\uD83D\uDCE6',
|
||||
configFields: [],
|
||||
comingSoon: true,
|
||||
},
|
||||
{
|
||||
id: 'entrust',
|
||||
name: 'Entrust',
|
||||
description: 'Entrust Certificate Services \u2014 coming soon',
|
||||
icon: '\uD83D\uDCE6',
|
||||
configFields: [],
|
||||
comingSoon: true,
|
||||
},
|
||||
];
|
||||
|
||||
/** Sensitive config key patterns for redaction in display */
|
||||
const SENSITIVE_PATTERNS = ['password', 'secret', 'token', 'key', 'hmac', 'private'];
|
||||
|
||||
/** Check if a config key should be redacted */
|
||||
export function isSensitiveKey(key: string): boolean {
|
||||
const lower = key.toLowerCase();
|
||||
return SENSITIVE_PATTERNS.some(p => lower.includes(p));
|
||||
}
|
||||
|
||||
/** Redact sensitive values in a config object */
|
||||
export function redactConfig(config: Record<string, unknown>): Record<string, unknown> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(config).map(([k, v]) => [k, isSensitiveKey(k) ? '********' : v])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns catalog status info per issuer type.
|
||||
* M36 (Onboarding) will use this to detect first-run state.
|
||||
*/
|
||||
export function getIssuerCatalogStatus(
|
||||
configuredIssuers: { type: string }[]
|
||||
): { type: IssuerTypeConfig; status: 'connected' | 'available' | 'coming_soon'; count: number }[] {
|
||||
return issuerTypes.map(t => {
|
||||
if (t.comingSoon) {
|
||||
return { type: t, status: 'coming_soon' as const, count: 0 };
|
||||
}
|
||||
// Match both the canonical id and common aliases
|
||||
const aliases: Record<string, string[]> = {
|
||||
local: ['local', 'local_ca'],
|
||||
};
|
||||
const matchIds = aliases[t.id] || [t.id];
|
||||
const matching = configuredIssuers.filter(i => matchIds.includes(i.type));
|
||||
return {
|
||||
type: t,
|
||||
status: matching.length > 0 ? 'connected' as const : 'available' as const,
|
||||
count: matching.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -13,6 +13,14 @@ const OS_COLORS: Record<string, string> = {
|
||||
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> = {
|
||||
Online: '#10b981',
|
||||
Offline: '#ef4444',
|
||||
@@ -86,7 +94,7 @@ export default function AgentFleetPage() {
|
||||
return acc;
|
||||
}, {});
|
||||
const osPieData = Object.entries(osDistribution).map(([name, value]) => ({
|
||||
name,
|
||||
name: displayOS(name),
|
||||
value,
|
||||
fill: OS_COLORS[name.toLowerCase()] || '#64748b',
|
||||
}));
|
||||
@@ -216,7 +224,7 @@ export default function AgentFleetPage() {
|
||||
style={{ backgroundColor: OS_COLORS[group.os.toLowerCase()] || '#64748b' }}
|
||||
/>
|
||||
<h4 className="text-sm font-medium text-ink">
|
||||
{group.os} / {group.arch}
|
||||
{displayOS(group.os)} / {group.arch}
|
||||
</h4>
|
||||
<span className="text-xs text-ink-faint">
|
||||
{group.agents.length} agent{group.agents.length !== 1 ? 's' : ''}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
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 PageHeader from '../components/PageHeader';
|
||||
import DataTable from '../components/DataTable';
|
||||
@@ -16,20 +16,66 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
name: '',
|
||||
id: '',
|
||||
common_name: '',
|
||||
sans: '',
|
||||
environment: 'production',
|
||||
issuer_id: '',
|
||||
certificate_profile_id: '',
|
||||
owner_id: '',
|
||||
team_id: '',
|
||||
renewal_policy_id: '',
|
||||
tags: '',
|
||||
});
|
||||
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({
|
||||
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(),
|
||||
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 (
|
||||
<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()}>
|
||||
@@ -39,57 +85,90 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
||||
<div>
|
||||
<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 }))}
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<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 }))}
|
||||
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)" />
|
||||
</div>
|
||||
<div>
|
||||
<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 }))}
|
||||
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" />
|
||||
</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>
|
||||
<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 }))}
|
||||
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="staging">Staging</option>
|
||||
<option value="development">Development</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Issuer ID *</label>
|
||||
<input value={form.issuer_id} onChange={e => setForm(f => ({ ...f, issuer_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"
|
||||
placeholder="iss-local" />
|
||||
<label className="text-xs text-ink-muted block mb-1">Policy</label>
|
||||
<input value={form.renewal_policy_id} onChange={e => setForm(f => ({ ...f, renewal_policy_id: e.target.value }))}
|
||||
className={inputClass}
|
||||
placeholder="rp-standard" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<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 }))}
|
||||
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" />
|
||||
</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 }))}
|
||||
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" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Policy ID</label>
|
||||
<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"
|
||||
placeholder="rp-standard" />
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs text-ink-muted block mb-1">Tags</label>
|
||||
<input value={form.tags} onChange={e => setForm(f => ({ ...f, tags: e.target.value }))}
|
||||
className={inputClass}
|
||||
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 className="flex justify-end gap-3 mt-6">
|
||||
@@ -245,15 +324,25 @@ export default function CertificatesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [statusFilter, setStatusFilter] = useState('');
|
||||
const [envFilter, setEnvFilter] = useState('');
|
||||
const [issuerFilter, setIssuerFilter] = useState('');
|
||||
const [ownerFilter, setOwnerFilter] = useState('');
|
||||
const [profileFilter, setProfileFilter] = useState('');
|
||||
const [showCreate, setShowCreate] = useState(false);
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
const [showBulkRevoke, setShowBulkRevoke] = useState(false);
|
||||
const [showBulkReassign, setShowBulkReassign] = useState(false);
|
||||
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> = {};
|
||||
if (statusFilter) params.status = statusFilter;
|
||||
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({
|
||||
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: '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="development">Development</option>
|
||||
</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 className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
|
||||
@@ -197,6 +197,18 @@ export default function DiscoveryPage() {
|
||||
label: 'Expiry',
|
||||
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',
|
||||
label: 'Fingerprint',
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useQuery, useMutation } from '@tanstack/react-query';
|
||||
import { getIssuer, testIssuerConnection, getCertificates } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
@@ -7,15 +7,8 @@ import DataTable from '../components/DataTable';
|
||||
import type { Column } from '../components/DataTable';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Certificate } from '../api/types';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME (Let\'s Encrypt)',
|
||||
step_ca: 'step-ca',
|
||||
openssl: 'OpenSSL / Custom',
|
||||
vault: 'Vault PKI',
|
||||
};
|
||||
import type { Certificate, Issuer } from '../api/types';
|
||||
import { typeLabels, redactConfig } from '../config/issuerTypes';
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
return (
|
||||
@@ -26,8 +19,17 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** Derive display status from backend enabled boolean */
|
||||
function issuerStatus(issuer: Issuer): string {
|
||||
if (issuer.enabled !== undefined) {
|
||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
return issuer.status || 'Unknown';
|
||||
}
|
||||
|
||||
export default function IssuerDetailPage() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
|
||||
const { data: issuer, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuer', id],
|
||||
@@ -65,13 +67,7 @@ export default function IssuerDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// Redact sensitive config fields
|
||||
const safeConfig = issuer.config ? Object.fromEntries(
|
||||
Object.entries(issuer.config).map(([k, v]) => {
|
||||
const sensitive = ['password', 'secret', 'token', 'key', 'hmac', 'private'].some(s => k.toLowerCase().includes(s));
|
||||
return [k, sensitive ? '********' : v];
|
||||
})
|
||||
) : {};
|
||||
const safeConfig = issuer.config ? redactConfig(issuer.config) : {};
|
||||
|
||||
const certColumns: Column<Certificate>[] = [
|
||||
{
|
||||
@@ -94,13 +90,21 @@ export default function IssuerDetailPage() {
|
||||
title={issuer.name}
|
||||
subtitle={typeLabels[issuer.type] || issuer.type}
|
||||
action={
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => navigate(`/issuers?edit=${issuer.id}`)}
|
||||
className="px-3 py-1.5 border border-surface-border rounded text-ink text-xs hover:bg-surface-hover transition-colors font-medium"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => testMutation.mutate()}
|
||||
disabled={testMutation.isPending}
|
||||
className="btn btn-primary text-xs disabled:opacity-50"
|
||||
>
|
||||
{testMutation.isPending ? 'Testing...' : 'Test Connection'}
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -123,7 +127,7 @@ export default function IssuerDetailPage() {
|
||||
<InfoRow label="ID" value={<span className="font-mono text-xs">{issuer.id}</span>} />
|
||||
<InfoRow label="Name" value={issuer.name} />
|
||||
<InfoRow label="Type" value={typeLabels[issuer.type] || issuer.type} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={issuer.status} />} />
|
||||
<InfoRow label="Status" value={<StatusBadge status={issuerStatus(issuer)} />} />
|
||||
<InfoRow label="Created" value={formatDateTime(issuer.created_at)} />
|
||||
</div>
|
||||
|
||||
|
||||
+202
-209
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useMemo } from 'react';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getIssuers, testIssuerConnection, deleteIssuer, createIssuer } from '../api/client';
|
||||
@@ -9,83 +9,27 @@ import StatusBadge from '../components/StatusBadge';
|
||||
import ErrorState from '../components/ErrorState';
|
||||
import { formatDateTime } from '../api/utils';
|
||||
import type { Issuer } from '../api/types';
|
||||
import { issuerTypes, typeLabels, getIssuerCatalogStatus, type IssuerTypeConfig } from '../config/issuerTypes';
|
||||
import TypeSelector from '../components/issuer/TypeSelector';
|
||||
import ConfigForm from '../components/issuer/ConfigForm';
|
||||
import ConfigDetailModal from '../components/issuer/ConfigDetailModal';
|
||||
|
||||
const typeLabels: Record<string, string> = {
|
||||
local_ca: 'Local CA',
|
||||
acme: 'ACME',
|
||||
stepca: 'step-ca',
|
||||
openssl: 'OpenSSL/Custom',
|
||||
vault: 'Vault PKI',
|
||||
manual: 'Manual',
|
||||
};
|
||||
|
||||
interface IssuerConfigField {
|
||||
key: string;
|
||||
label: string;
|
||||
placeholder?: string;
|
||||
required: boolean;
|
||||
type?: string;
|
||||
options?: string[];
|
||||
defaultValue?: string;
|
||||
/** Derive display status from backend enabled boolean */
|
||||
function issuerStatus(issuer: Issuer): string {
|
||||
if (issuer.enabled !== undefined) {
|
||||
return issuer.enabled ? 'Enabled' : 'Disabled';
|
||||
}
|
||||
// Fallback for legacy data that may have status string
|
||||
return issuer.status || 'Unknown';
|
||||
}
|
||||
|
||||
interface IssuerTypeConfig {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string;
|
||||
configFields: IssuerConfigField[];
|
||||
}
|
||||
|
||||
const issuerTypes: IssuerTypeConfig[] = [
|
||||
{
|
||||
id: 'local_ca',
|
||||
name: 'Local CA',
|
||||
description: 'Self-signed or subordinate CA for certificate issuance',
|
||||
configFields: [
|
||||
{ key: 'ca_cert_path', label: 'CA Cert Path (optional)', placeholder: '/path/to/ca.crt', required: false },
|
||||
{ key: 'ca_key_path', label: 'CA Key Path (optional)', placeholder: '/path/to/ca.key', required: false },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'acme',
|
||||
name: 'ACME',
|
||||
description: "Let's Encrypt or other ACME-compatible CA",
|
||||
configFields: [
|
||||
{ key: 'directory_url', label: 'Directory URL', placeholder: 'https://acme-v02.api.letsencrypt.org/directory', required: true },
|
||||
{ key: 'email', label: 'Email', placeholder: 'admin@example.com', required: true },
|
||||
{ key: 'challenge_type', label: 'Challenge Type', type: 'select', options: ['http-01', 'dns-01', 'dns-persist-01'], required: false, defaultValue: 'http-01' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'stepca',
|
||||
name: 'step-ca',
|
||||
description: 'Smallstep private CA',
|
||||
configFields: [
|
||||
{ key: 'ca_url', label: 'CA URL', placeholder: 'https://ca.example.com', required: true },
|
||||
{ key: 'provisioner_name', label: 'Provisioner Name', placeholder: 'my-provisioner', required: true },
|
||||
{ key: 'provisioner_key', label: 'Provisioner Key (JWK)', placeholder: '{...}', type: 'textarea', required: true },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'openssl',
|
||||
name: 'OpenSSL/Custom',
|
||||
description: 'Script-based signing with your own CA',
|
||||
configFields: [
|
||||
{ key: 'sign_script', label: 'Sign Script Path', placeholder: '/path/to/sign.sh', required: true },
|
||||
{ key: 'revoke_script', label: 'Revoke Script Path (optional)', placeholder: '/path/to/revoke.sh', required: false },
|
||||
{ key: 'crl_script', label: 'CRL Script Path (optional)', placeholder: '/path/to/crl.sh', required: false },
|
||||
{ key: 'timeout_seconds', label: 'Timeout (seconds)', placeholder: '30', type: 'number', required: false },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
export default function IssuersPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [testResult, setTestResult] = useState<{ id: string; ok: boolean; msg: string } | null>(null);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [createStep, setCreateStep] = useState<'type' | 'config'>('type');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(null);
|
||||
const [createForm, setCreateForm] = useState<Record<string, unknown>>({});
|
||||
const [preselectedType, setPreselectedType] = useState<string | null>(null);
|
||||
const [typeFilter, setTypeFilter] = useState<string>('');
|
||||
const [configModal, setConfigModal] = useState<{ title: string; config: Record<string, unknown> } | null>(null);
|
||||
|
||||
const { data, isLoading, error, refetch } = useQuery({
|
||||
queryKey: ['issuers'],
|
||||
@@ -109,12 +53,22 @@ export default function IssuersPage() {
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['issuers'] });
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
setPreselectedType(null);
|
||||
},
|
||||
});
|
||||
|
||||
const catalogStatus = useMemo(
|
||||
() => getIssuerCatalogStatus(data?.data || []),
|
||||
[data?.data]
|
||||
);
|
||||
|
||||
// Filter issuers by type
|
||||
const filteredIssuers = useMemo(() => {
|
||||
if (!data?.data) return [];
|
||||
if (!typeFilter) return data.data;
|
||||
return data.data.filter(i => i.type === typeFilter);
|
||||
}, [data?.data, typeFilter]);
|
||||
|
||||
const columns: Column<Issuer>[] = [
|
||||
{
|
||||
key: 'name',
|
||||
@@ -138,7 +92,7 @@ export default function IssuersPage() {
|
||||
{
|
||||
key: 'status',
|
||||
label: 'Status',
|
||||
render: (i) => <StatusBadge status={i.status} />,
|
||||
render: (i) => <StatusBadge status={issuerStatus(i)} />,
|
||||
},
|
||||
{
|
||||
key: 'config',
|
||||
@@ -146,9 +100,15 @@ export default function IssuersPage() {
|
||||
render: (i) => {
|
||||
if (!i.config || Object.keys(i.config).length === 0) return <span className="text-ink-faint">—</span>;
|
||||
return (
|
||||
<span className="text-xs text-ink-muted font-mono truncate max-w-xs block">
|
||||
{JSON.stringify(i.config).slice(0, 60)}
|
||||
</span>
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setConfigModal({ title: `${i.name} Configuration`, config: i.config });
|
||||
}}
|
||||
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
View Config
|
||||
</button>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -184,14 +144,12 @@ export default function IssuersPage() {
|
||||
<>
|
||||
<PageHeader
|
||||
title="Issuers"
|
||||
subtitle={data ? `${data.total} issuers` : undefined}
|
||||
subtitle={data ? `${data.total} configured` : undefined}
|
||||
action={
|
||||
<button
|
||||
onClick={() => {
|
||||
setPreselectedType(null);
|
||||
setShowCreateModal(true);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
}}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded font-medium hover:bg-brand-700 transition-colors text-sm"
|
||||
>
|
||||
@@ -205,49 +163,83 @@ export default function IssuersPage() {
|
||||
<button onClick={() => setTestResult(null)} className="ml-3 text-xs opacity-60 hover:opacity-100">dismiss</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error ? (
|
||||
<ErrorState error={error as Error} onRetry={() => refetch()} />
|
||||
) : (
|
||||
<DataTable columns={columns} data={data?.data || []} isLoading={isLoading} emptyMessage="No issuers configured" />
|
||||
<>
|
||||
{/* Issuer Type Catalog Cards */}
|
||||
<div className="px-6 py-4">
|
||||
<h3 className="text-sm font-semibold text-ink-muted mb-3">Issuer Types</h3>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-3">
|
||||
{catalogStatus.map(({ type, status, count }) => (
|
||||
<CatalogCard
|
||||
key={type.id}
|
||||
type={type}
|
||||
status={status}
|
||||
count={count}
|
||||
onConfigure={() => {
|
||||
setPreselectedType(type.id);
|
||||
setShowCreateModal(true);
|
||||
}}
|
||||
onFilter={() => {
|
||||
// Match both the canonical id and aliases
|
||||
const filterValue = type.id === 'local' ? 'local' : type.id;
|
||||
setTypeFilter(prev => prev === filterValue ? '' : filterValue);
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Configured Issuers Table */}
|
||||
<div className="px-6 pb-4">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="text-sm font-semibold text-ink-muted">Configured Issuers</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="text-xs px-2 py-1.5 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500"
|
||||
>
|
||||
<option value="">All Types</option>
|
||||
{issuerTypes.filter(t => !t.comingSoon).map(t => (
|
||||
<option key={t.id} value={t.id}>{t.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<DataTable
|
||||
columns={columns}
|
||||
data={filteredIssuers}
|
||||
isLoading={isLoading}
|
||||
emptyMessage={typeFilter ? `No ${typeLabels[typeFilter] || typeFilter} issuers configured` : 'No issuers configured'}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Config Detail Modal */}
|
||||
{configModal && (
|
||||
<ConfigDetailModal
|
||||
title={configModal.title}
|
||||
config={configModal.config}
|
||||
onClose={() => setConfigModal(null)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Create Issuer Modal */}
|
||||
{showCreateModal && (
|
||||
<CreateIssuerModal
|
||||
step={createStep}
|
||||
selectedType={selectedType}
|
||||
form={createForm}
|
||||
onTypeSelect={(type) => {
|
||||
setSelectedType(type);
|
||||
const typeConfig = issuerTypes.find((t) => t.id === type);
|
||||
const defaultConfig: Record<string, unknown> = {};
|
||||
if (typeConfig) {
|
||||
typeConfig.configFields.forEach((field) => {
|
||||
if (field.defaultValue) {
|
||||
defaultConfig[field.key] = field.defaultValue;
|
||||
}
|
||||
});
|
||||
}
|
||||
setCreateForm({ ...defaultConfig });
|
||||
setCreateStep('config');
|
||||
}}
|
||||
onFormChange={(field, value) => {
|
||||
setCreateForm({ ...createForm, [field]: value });
|
||||
}}
|
||||
onBack={() => setCreateStep('type')}
|
||||
onSubmit={() => {
|
||||
if (!selectedType || !createForm.name) return;
|
||||
const config: Record<string, unknown> = { ...createForm };
|
||||
const name = config.name as string;
|
||||
delete config.name;
|
||||
createMutation.mutate({ name, type: selectedType, config });
|
||||
preselectedType={preselectedType}
|
||||
onSubmit={(name, type, config) => {
|
||||
createMutation.mutate({ name, type, config });
|
||||
}}
|
||||
onCancel={() => {
|
||||
setShowCreateModal(false);
|
||||
setCreateStep('type');
|
||||
setSelectedType(null);
|
||||
setCreateForm({});
|
||||
setPreselectedType(null);
|
||||
}}
|
||||
isSubmitting={createMutation.isPending}
|
||||
/>
|
||||
@@ -256,30 +248,94 @@ export default function IssuersPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Catalog Card ───────────────────────────────────────────────
|
||||
|
||||
interface CatalogCardProps {
|
||||
type: IssuerTypeConfig;
|
||||
status: 'connected' | 'available' | 'coming_soon';
|
||||
count: number;
|
||||
onConfigure: () => void;
|
||||
onFilter: () => void;
|
||||
}
|
||||
|
||||
function CatalogCard({ type, status, count, onConfigure, onFilter }: CatalogCardProps) {
|
||||
const statusConfig = {
|
||||
connected: { label: `${count} configured`, cls: 'bg-emerald-500/10 text-emerald-400 border-emerald-500/30' },
|
||||
available: { label: 'Available', cls: 'bg-brand-500/10 text-brand-400 border-brand-500/30' },
|
||||
coming_soon: { label: 'Coming Soon', cls: 'bg-gray-500/10 text-gray-400 border-gray-500/30' },
|
||||
};
|
||||
const { label, cls } = statusConfig[status];
|
||||
|
||||
return (
|
||||
<div className={`p-4 border rounded-lg ${status === 'coming_soon' ? 'border-surface-border/50 opacity-60' : 'border-surface-border'}`}>
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg">{type.icon}</span>
|
||||
<span className="font-medium text-ink text-sm">{type.name}</span>
|
||||
</div>
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full border ${cls}`}>{label}</span>
|
||||
</div>
|
||||
<p className="text-xs text-ink-muted mb-3">{type.description}</p>
|
||||
{status === 'connected' && (
|
||||
<button
|
||||
onClick={onFilter}
|
||||
className="text-xs text-brand-400 hover:text-brand-500 transition-colors"
|
||||
>
|
||||
View issuers
|
||||
</button>
|
||||
)}
|
||||
{status === 'available' && (
|
||||
<button
|
||||
onClick={onConfigure}
|
||||
className="text-xs px-3 py-1 bg-brand-600 text-white rounded hover:bg-brand-700 transition-colors"
|
||||
>
|
||||
Configure
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ─── Create Issuer Modal ────────────────────────────────────────
|
||||
|
||||
interface CreateIssuerModalProps {
|
||||
step: 'type' | 'config';
|
||||
selectedType: string | null;
|
||||
form: Record<string, unknown>;
|
||||
onTypeSelect: (type: string) => void;
|
||||
onFormChange: (field: string, value: unknown) => void;
|
||||
onBack: () => void;
|
||||
onSubmit: () => void;
|
||||
preselectedType: string | null;
|
||||
onSubmit: (name: string, type: string, config: Record<string, unknown>) => void;
|
||||
onCancel: () => void;
|
||||
isSubmitting: boolean;
|
||||
}
|
||||
|
||||
function CreateIssuerModal({
|
||||
step,
|
||||
selectedType,
|
||||
form,
|
||||
onTypeSelect,
|
||||
onFormChange,
|
||||
onBack,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
isSubmitting,
|
||||
}: CreateIssuerModalProps) {
|
||||
const selectedTypeConfig = issuerTypes.find((t) => t.id === selectedType);
|
||||
function CreateIssuerModal({ preselectedType, onSubmit, onCancel, isSubmitting }: CreateIssuerModalProps) {
|
||||
const [step, setStep] = useState<'type' | 'config'>(preselectedType ? 'config' : 'type');
|
||||
const [selectedType, setSelectedType] = useState<string | null>(preselectedType);
|
||||
const [form, setForm] = useState<Record<string, unknown>>(() => {
|
||||
if (preselectedType) {
|
||||
const tc = issuerTypes.find(t => t.id === preselectedType);
|
||||
const defaults: Record<string, unknown> = {};
|
||||
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||
return defaults;
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const selectedTypeConfig = issuerTypes.find(t => t.id === selectedType);
|
||||
|
||||
function handleTypeSelect(typeId: string) {
|
||||
setSelectedType(typeId);
|
||||
const tc = issuerTypes.find(t => t.id === typeId);
|
||||
const defaults: Record<string, unknown> = {};
|
||||
tc?.configFields.forEach(f => { if (f.defaultValue) defaults[f.key] = f.defaultValue; });
|
||||
setForm(defaults);
|
||||
setStep('config');
|
||||
}
|
||||
|
||||
function handleSubmit() {
|
||||
if (!selectedType || !form.name) return;
|
||||
const config = { ...form };
|
||||
const name = config.name as string;
|
||||
delete config.name;
|
||||
onSubmit(name, selectedType, config);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black bg-opacity-50 z-50 flex items-center justify-center">
|
||||
@@ -289,10 +345,7 @@ function CreateIssuerModal({
|
||||
<h2 className="text-lg font-semibold text-ink">
|
||||
{step === 'type' ? 'Create Issuer' : `Configure ${selectedTypeConfig?.name || 'Issuer'}`}
|
||||
</h2>
|
||||
<button
|
||||
onClick={onCancel}
|
||||
className="text-ink-muted hover:text-ink transition-colors"
|
||||
>
|
||||
<button onClick={onCancel} className="text-ink-muted hover:text-ink transition-colors">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
@@ -300,79 +353,28 @@ function CreateIssuerModal({
|
||||
{/* Content */}
|
||||
<div className="px-6 py-6">
|
||||
{step === 'type' ? (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
{issuerTypes.map((type) => (
|
||||
<button
|
||||
key={type.id}
|
||||
onClick={() => onTypeSelect(type.id)}
|
||||
className="p-4 border border-surface-border rounded-lg hover:border-brand-500 hover:bg-opacity-5 transition-all text-left"
|
||||
>
|
||||
<div className="font-medium text-ink">{type.name}</div>
|
||||
<div className="text-sm text-ink-muted mt-1">{type.description}</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<TypeSelector onSelect={handleTypeSelect} />
|
||||
) : (
|
||||
<div className="space-y-5">
|
||||
{/* Name field always shown */}
|
||||
{/* Name field */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-ink mb-2">Issuer Name *</label>
|
||||
<input
|
||||
type="text"
|
||||
value={(form.name as string) || ''}
|
||||
onChange={(e) => onFormChange('name', e.target.value)}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="e.g., Production CA"
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Type-specific fields */}
|
||||
{selectedTypeConfig?.configFields.map((field) => (
|
||||
<div key={field.key}>
|
||||
<label className="block text-sm font-medium text-ink mb-2">
|
||||
{field.label}
|
||||
{field.required && <span className="text-red-600 ml-1">*</span>}
|
||||
</label>
|
||||
{field.type === 'select' ? (
|
||||
<select
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink focus:outline-none focus:border-brand-500 transition-colors"
|
||||
>
|
||||
<option value="">Select {field.label}</option>
|
||||
{field.options?.map((opt) => (
|
||||
<option key={opt} value={opt}>
|
||||
{opt}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : field.type === 'textarea' ? (
|
||||
<textarea
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors font-mono text-xs"
|
||||
/>
|
||||
) : field.type === 'number' ? (
|
||||
<input
|
||||
type="number"
|
||||
value={(form[field.key] as number | string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value ? parseInt(e.target.value, 10) : '')}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
type="text"
|
||||
value={(form[field.key] as string) || ''}
|
||||
onChange={(e) => onFormChange(field.key, e.target.value)}
|
||||
placeholder={field.placeholder}
|
||||
className="w-full px-3 py-2 bg-surface border border-surface-border rounded text-ink placeholder-ink-faint focus:outline-none focus:border-brand-500 transition-colors"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{/* Type-specific fields via ConfigForm */}
|
||||
{selectedTypeConfig && (
|
||||
<ConfigForm
|
||||
fields={selectedTypeConfig.configFields}
|
||||
values={form}
|
||||
onChange={(key, value) => setForm({ ...form, [key]: value })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -381,7 +383,7 @@ function CreateIssuerModal({
|
||||
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-3">
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onBack}
|
||||
onClick={() => setStep('type')}
|
||||
className="px-4 py-2 border border-surface-border rounded text-ink hover:bg-surface-hover transition-colors text-sm font-medium"
|
||||
>
|
||||
Back
|
||||
@@ -395,22 +397,13 @@ function CreateIssuerModal({
|
||||
</button>
|
||||
{step === 'config' && (
|
||||
<button
|
||||
onClick={onSubmit}
|
||||
onClick={handleSubmit}
|
||||
disabled={isSubmitting || !form.name}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{isSubmitting ? 'Creating...' : 'Create Issuer'}
|
||||
</button>
|
||||
)}
|
||||
{step === 'type' && (
|
||||
<button
|
||||
onClick={() => selectedType && onTypeSelect(selectedType)}
|
||||
disabled={!selectedType}
|
||||
className="px-4 py-2 bg-brand-600 text-white rounded text-sm font-medium hover:bg-brand-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -136,6 +136,15 @@ export default function JobsPage() {
|
||||
label: 'Attempts',
|
||||
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: 'completed', label: 'Completed', render: (j) => <span className="text-xs text-ink-muted">{formatDateTime(j.completed_at)}</span> },
|
||||
{
|
||||
|
||||
@@ -25,11 +25,63 @@ interface CreateProfileModalProps {
|
||||
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) {
|
||||
const [name, setName] = useState('');
|
||||
const [description, setDescription] = useState('');
|
||||
const [ttl, setTtl] = useState('86400');
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -39,20 +91,31 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
description: description.trim(),
|
||||
max_ttl_seconds: parseInt(ttl) || 86400,
|
||||
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,
|
||||
});
|
||||
setName('');
|
||||
setDescription('');
|
||||
setTtl('86400');
|
||||
setShortLived(false);
|
||||
setKeyAlgorithms([{ algorithm: 'ECDSA', min_size: 256 }, { algorithm: 'RSA', min_size: 2048 }]);
|
||||
setSelectedEkus(['serverAuth']);
|
||||
setSanPatterns('');
|
||||
setSpiffePattern('');
|
||||
onSuccess();
|
||||
};
|
||||
|
||||
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 (
|
||||
<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>
|
||||
{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">
|
||||
@@ -61,7 +124,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
<input
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
className={inputClass}
|
||||
placeholder="e.g., Web Server Certs"
|
||||
required
|
||||
/>
|
||||
@@ -71,7 +134,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
<textarea
|
||||
value={description}
|
||||
onChange={e => setDescription(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
className={inputClass}
|
||||
placeholder="Optional description"
|
||||
rows={2}
|
||||
/>
|
||||
@@ -82,7 +145,7 @@ function CreateProfileModal({ isOpen, onClose, onSuccess, isLoading, error }: Cr
|
||||
type="number"
|
||||
value={ttl}
|
||||
onChange={e => setTtl(e.target.value)}
|
||||
className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400"
|
||||
className={inputClass}
|
||||
placeholder="86400"
|
||||
/>
|
||||
<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>
|
||||
</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">
|
||||
<button
|
||||
type="submit"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { getTarget, getJobs } from '../api/client';
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { getTarget, getJobs, updateTarget } from '../api/client';
|
||||
import PageHeader from '../components/PageHeader';
|
||||
import StatusBadge from '../components/StatusBadge';
|
||||
import DataTable from '../components/DataTable';
|
||||
@@ -30,6 +31,18 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
|
||||
|
||||
export default function TargetDetailPage() {
|
||||
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({
|
||||
queryKey: ['target', id],
|
||||
@@ -112,6 +125,18 @@ export default function TargetDetailPage() {
|
||||
<PageHeader
|
||||
title={target.name}
|
||||
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">
|
||||
@@ -164,6 +189,36 @@ export default function TargetDetailPage() {
|
||||
/>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user