mirror of
https://github.com/shankar0123/certctl.git
synced 2026-06-08 09:58:51 +00:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 700c399367 | |||
| 1fcb05181d | |||
| 508c7530e9 | |||
| c9f932be65 | |||
| 868f1c25be | |||
| 9ce2d8ca8f | |||
| 0987e222dd | |||
| e761ae40a4 | |||
| 1daae5d709 | |||
| 7c01f811a1 | |||
| c1b581b047 | |||
| e37403edf1 | |||
| 93e00f6a5e | |||
| c8985cf868 | |||
| 155f1fec98 | |||
| 29cb13e7a2 | |||
| 9135c44908 | |||
| 952682ebec | |||
| a41fc2d75c | |||
| c8347d742d | |||
| 67f346cd87 |
@@ -132,6 +132,18 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/api/router/... ./internal/auth/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... ./internal/ciparity/... -count=1 -cover -coverprofile=coverage.out
|
go test ./internal/service/... ./internal/api/handler/... ./internal/api/middleware/... ./internal/api/router/... ./internal/auth/... ./internal/integration/... ./internal/connector/issuer/... ./internal/connector/target/... ./internal/connector/notifier/... ./internal/connector/discovery/... ./internal/crypto/... ./internal/mcp/... ./internal/cli/... ./internal/domain/... ./internal/validation/... ./internal/tlsprobe/... ./internal/ciparity/... -count=1 -cover -coverprofile=coverage.out
|
||||||
|
|
||||||
|
- name: Multi-replica rate-limit integration test (Phase 13 Sprint 13.2/13.3 — ARCH-M1 closure proof)
|
||||||
|
# The falsifiable proof that CERTCTL_RATE_LIMIT_BACKEND=postgres
|
||||||
|
# enforces caps cluster-wide. testcontainers-go spins one
|
||||||
|
# Postgres container; 3 *PostgresSlidingWindowLimiter instances
|
||||||
|
# share it; 100 concurrent Allow("test-key") with cap=10 must
|
||||||
|
# see exactly 10 succeed + 90 ErrRateLimited. Failure here =
|
||||||
|
# the row-lock arbitration broke; ARCH-M1 closure is invalid.
|
||||||
|
run: |
|
||||||
|
go test -tags=integration -race -count=1 -timeout=300s \
|
||||||
|
-run TestRateLimit_PostgresBackend_CapEnforcedAcrossReplicas \
|
||||||
|
./internal/integration/...
|
||||||
|
|
||||||
- name: Check Coverage Thresholds
|
- name: Check Coverage Thresholds
|
||||||
# ci-pipeline-cleanup Phase 2: per-package floors moved to
|
# ci-pipeline-cleanup Phase 2: per-package floors moved to
|
||||||
# .github/coverage-thresholds.yml. Each entry has `floor:` +
|
# .github/coverage-thresholds.yml. Each entry has `floor:` +
|
||||||
|
|||||||
@@ -92,10 +92,12 @@ Security: three authentication paths — API keys (SHA-256 hashed + constant-tim
|
|||||||
```bash
|
```bash
|
||||||
git clone https://github.com/certctl-io/certctl.git
|
git clone https://github.com/certctl-io/certctl.git
|
||||||
cd certctl
|
cd certctl
|
||||||
docker compose -f deploy/docker-compose.yml -f deploy/docker-compose.demo.yml up -d --build
|
./deploy/demo-up.sh -d --build
|
||||||
```
|
```
|
||||||
|
|
||||||
Wait ~30 seconds, then open **https://localhost:8443** in your browser. The demo overlay flips the base into demo-mode auth (every request served as the synthetic admin actor `actor-demo-anon` — the server emits a prominent ⚠ DEMO MODE banner at boot reminding you this posture is for evaluation only) and seeds 180 days of realistic history across 13 issuers, 8 agents, managed + discovered certs, jobs, deploys, audit, and notification events. The `certctl-tls-init` init container self-signs an ECDSA-P256 cert on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.
|
Wait ~30 seconds, then open **https://localhost:8443** in your browser. The `demo-up.sh` wrapper exports a fresh `CERTCTL_DEMO_MODE_ACK_TS=$(date +%s)` and forwards the remaining args to `docker compose -f docker-compose.yml -f docker-compose.demo.yml up`. The timestamp export is required by the Phase 2 SEC-H3 fail-closed guard in `internal/config/config.go::Validate` — demo deploys must re-ACK every 24h so a forgotten demo container never silently ends up serving production traffic with `auth-type=none`. The bare `docker compose ... up` command without the timestamp refuses to boot; the wrapper script is the supported entry point.
|
||||||
|
|
||||||
|
The demo overlay flips the base into demo-mode auth (every request served as the synthetic admin actor `actor-demo-anon` — the server emits a prominent ⚠ DEMO MODE banner at boot reminding you this posture is for evaluation only) and seeds 180 days of realistic history across 13 issuers, 8 agents, managed + discovered certs, jobs, deploys, audit, and notification events. The `certctl-tls-init` init container self-signs an ECDSA-P256 cert on first boot — accept the browser warning for the demo, or feed the generated `ca.crt` to your client.
|
||||||
|
|
||||||
**Production path — `.env` required, fail-closed on placeholders:**
|
**Production path — `.env` required, fail-closed on placeholders:**
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
0
|
||||||
@@ -1,48 +1,100 @@
|
|||||||
# Routes registered in internal/api/router/router.go that are intentionally
|
# Routes registered in internal/api/router/router.go that are intentionally
|
||||||
# NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification.
|
# NOT in api/openapi.yaml. Each entry needs a one-line `why:` justification
|
||||||
|
# AND a required `category:` field (added in Phase 13 Sprint 13.1,
|
||||||
|
# 2026-05-14, architecture diligence audit ARCH-H1).
|
||||||
|
#
|
||||||
# Adding a new entry requires PR-time review.
|
# Adding a new entry requires PR-time review.
|
||||||
#
|
#
|
||||||
# OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here.
|
# OpenAPI-shaped REST endpoints belong in api/openapi.yaml, NOT here.
|
||||||
# This list is for protocol-shaped (SCEP wire endpoints) and operational
|
# This list is for protocol-shaped (SCEP/ACME/EST wire endpoints) and
|
||||||
# (health, metrics, pprof) routes only.
|
# operational (health, metrics, pprof) routes only.
|
||||||
#
|
#
|
||||||
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
|
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
|
||||||
#
|
#
|
||||||
# Phase 5 reconciliation (2026-05-13, architecture diligence audit
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
# ARCH-H1): of the 64 entries below, 35 are legitimate wire-protocol
|
# The two-bucket contract (Phase 13 Sprint 13.1)
|
||||||
# carve-outs (SCEP RFC 8894 = 8 entries, ACME RFC 8555 default + per-
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
# profile = 27 entries) that MUST stay. The remaining 29 are REST-
|
|
||||||
# shaped routes whose OpenAPI ops were deferred during their original
|
|
||||||
# Bundle 2 / audit-2026-05-10 / 2026-05-11 work. Burn-down plan:
|
|
||||||
#
|
#
|
||||||
# Sprint A (per-cluster, ~7-8 ops each):
|
# category: wire-protocol
|
||||||
# Cluster 1: auth/sessions + auth/oidc (12 ops)
|
# The route's wire shape is dictated by an IETF RFC (SCEP RFC 8894,
|
||||||
# Cluster 2: auth/breakglass + auth/users + auth/runtime-config (8 ops)
|
# ACME RFC 8555, ACME ARI RFC 9773, EST RFC 7030) or it's a
|
||||||
# Cluster 3: audit/export + demo-residual/cleanup + auth/logout +
|
# sibling/shorthand variant of such a route (same wire semantics,
|
||||||
# auth/breakglass/login + auth/oidc/{login,callback,bcl} (9 ops)
|
# different cosmetic path — e.g. trailing-slash forms, default-
|
||||||
|
# profile shorthands). Documenting these as REST operations in
|
||||||
|
# openapi.yaml would duplicate the RFC with no information gain;
|
||||||
|
# the canonical operator references live in docs/acme-server.md +
|
||||||
|
# docs/operator/scep.md + docs/operator/est.md. These entries
|
||||||
|
# NEVER burn down — they're protocol contracts, not gaps.
|
||||||
|
#
|
||||||
|
# category: rest-deferred
|
||||||
|
# The route is REST-shaped (resource CRUD, JSON request/response,
|
||||||
|
# RBAC-gated) but its OpenAPI operation was deferred when the
|
||||||
|
# handler shipped. These MUST monotonically decrease to zero.
|
||||||
|
# Phase 13 Sprints 13.4-13.6 author the OpenAPI ops + delete the
|
||||||
|
# corresponding exception entries; the
|
||||||
|
# openapi-rest-deferred-monotonic.sh CI guard fails any PR that
|
||||||
|
# grows the rest-deferred bucket vs the checked-in baseline at
|
||||||
|
# api/openapi-handler-exceptions-baseline.txt.
|
||||||
|
#
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
# Phase 13 Sprint 13.1 categorization (2026-05-14)
|
||||||
|
# ──────────────────────────────────────────────────────────────────────
|
||||||
|
#
|
||||||
|
# Current split, re-derived by the parity script's bucket-reporting
|
||||||
|
# subcommand (post-Sprint-13.6 / 2026-05-14):
|
||||||
|
#
|
||||||
|
# total entries: 36
|
||||||
|
# wire-protocol: 36
|
||||||
|
# rest-deferred: 0 ← THE FLOOR — ARCH-H1 substantive close
|
||||||
|
#
|
||||||
|
# Burn-down progress:
|
||||||
|
#
|
||||||
|
# Sprint 13.4 SHIPPED — 28 - 13 = 15 (auth/sessions cluster 3 ops +
|
||||||
|
# auth/oidc CRUD + JWKS + test + refresh
|
||||||
|
# + group-mappings cluster, 10 ops)
|
||||||
|
# Sprint 13.5 SHIPPED — 15 - 8 = 7 (auth/breakglass admin 4 ops +
|
||||||
|
# auth/users 3 ops + auth/runtime-config
|
||||||
|
# 1 op, 8 ops total)
|
||||||
|
# Sprint 13.6 SHIPPED — 7 - 7 = 0 (audit/export 1 op + demo-
|
||||||
|
# residual/cleanup 1 op + auth/logout 1 op +
|
||||||
|
# auth/breakglass/login 1 op + 3 OIDC
|
||||||
|
# browser-flow endpoints, 7 ops total)
|
||||||
|
#
|
||||||
|
# Sprint 13.7 next tightens the parity-script's rest-deferred floor
|
||||||
|
# from monotonic-decrease to a hard zero-exact pin. After that, any
|
||||||
|
# new REST route MUST land with an OpenAPI op or fail CI — no escape
|
||||||
|
# hatch via `category: rest-deferred`.
|
||||||
#
|
#
|
||||||
# Each authored OpenAPI op needs request/response schemas (not
|
# Each authored OpenAPI op needs request/response schemas (not
|
||||||
# placeholders) so the generated client at web/orval.config.ts emits
|
# placeholders) so the generated client at web/orval.config.ts emits
|
||||||
# typed signatures. When an op lands, delete the corresponding entry
|
# typed signatures. When an op lands, delete the corresponding entry
|
||||||
# below + bump the openapi-handler-parity.sh expected counts.
|
# below + bump api/openapi-handler-exceptions-baseline.txt downward.
|
||||||
|
|
||||||
documented_exceptions:
|
documented_exceptions:
|
||||||
- route: "GET /scep"
|
- route: "GET /scep"
|
||||||
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource."
|
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; serves CA certs via GetCACert/GetCACaps query params, NOT a REST resource."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /scep"
|
- route: "POST /scep"
|
||||||
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource."
|
why: "SCEP wire-protocol endpoint per RFC 8894 §3.1; receives PKCSReq / RenewalReq PKIMessages, NOT a REST resource."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /scep/"
|
- route: "GET /scep/"
|
||||||
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /scep/"
|
- route: "POST /scep/"
|
||||||
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
why: "SCEP wire-protocol endpoint with trailing-slash variant; ChromeOS clients send the trailing-slash form."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /scep-mtls"
|
- route: "GET /scep-mtls"
|
||||||
why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant."
|
why: "SCEP-mTLS sibling endpoint per ci-pipeline-cleanup-prerequisite EST RFC 7030 hardening Phase 6.5; same wire-protocol semantics, mutually-authenticated TLS variant."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /scep-mtls"
|
- route: "POST /scep-mtls"
|
||||||
why: "SCEP-mTLS sibling endpoint, POST variant."
|
why: "SCEP-mTLS sibling endpoint, POST variant."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /scep-mtls/"
|
- route: "GET /scep-mtls/"
|
||||||
why: "SCEP-mTLS sibling endpoint, trailing-slash variant."
|
why: "SCEP-mTLS sibling endpoint, trailing-slash variant."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /scep-mtls/"
|
- route: "POST /scep-mtls/"
|
||||||
why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant."
|
why: "SCEP-mTLS sibling endpoint, trailing-slash POST variant."
|
||||||
|
category: wire-protocol
|
||||||
|
|
||||||
# ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface.
|
# ACME server (RFC 8555 + RFC 9773 ARI) — wire-protocol surface.
|
||||||
# Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose
|
# Like SCEP/EST, ACME is a JWS-signed-JSON wire protocol whose
|
||||||
@@ -54,62 +106,90 @@ documented_exceptions:
|
|||||||
# challenge, cert, key-change, revoke-cert, renewal-info routes land.
|
# challenge, cert, key-change, revoke-cert, renewal-info routes land.
|
||||||
- route: "GET /acme/profile/{id}/directory"
|
- route: "GET /acme/profile/{id}/directory"
|
||||||
why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.1.1 directory; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "HEAD /acme/profile/{id}/new-nonce"
|
- route: "HEAD /acme/profile/{id}/new-nonce"
|
||||||
why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.2 new-nonce; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /acme/profile/{id}/new-nonce"
|
- route: "GET /acme/profile/{id}/new-nonce"
|
||||||
why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.2 new-nonce GET form; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/new-account"
|
- route: "POST /acme/profile/{id}/new-account"
|
||||||
why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.3 new-account (JWS jwk); documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/account/{acc_id}"
|
- route: "POST /acme/profile/{id}/account/{acc_id}"
|
||||||
why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.3.2 + §7.3.6 (JWS kid) account update + deactivation; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /acme/directory"
|
- route: "GET /acme/directory"
|
||||||
why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set."
|
why: "ACME server default-profile shorthand; mirrors per-profile when CERTCTL_ACME_SERVER_DEFAULT_PROFILE_ID is set."
|
||||||
|
category: wire-protocol
|
||||||
- route: "HEAD /acme/new-nonce"
|
- route: "HEAD /acme/new-nonce"
|
||||||
why: "ACME server default-profile shorthand for new-nonce HEAD."
|
why: "ACME server default-profile shorthand for new-nonce HEAD."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /acme/new-nonce"
|
- route: "GET /acme/new-nonce"
|
||||||
why: "ACME server default-profile shorthand for new-nonce GET."
|
why: "ACME server default-profile shorthand for new-nonce GET."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/new-account"
|
- route: "POST /acme/new-account"
|
||||||
why: "ACME server default-profile shorthand for new-account."
|
why: "ACME server default-profile shorthand for new-account."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/account/{acc_id}"
|
- route: "POST /acme/account/{acc_id}"
|
||||||
why: "ACME server default-profile shorthand for account update + deactivation."
|
why: "ACME server default-profile shorthand for account update + deactivation."
|
||||||
|
category: wire-protocol
|
||||||
|
|
||||||
# Phase 2 — orders + finalize + authz + cert.
|
# Phase 2 — orders + finalize + authz + cert.
|
||||||
- route: "POST /acme/profile/{id}/new-order"
|
- route: "POST /acme/profile/{id}/new-order"
|
||||||
why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.4 new-order; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/order/{ord_id}"
|
- route: "POST /acme/profile/{id}/order/{ord_id}"
|
||||||
why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.4 order POST-as-GET; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/order/{ord_id}/finalize"
|
- route: "POST /acme/profile/{id}/order/{ord_id}/finalize"
|
||||||
why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.4 finalize; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/authz/{authz_id}"
|
- route: "POST /acme/profile/{id}/authz/{authz_id}"
|
||||||
why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.5 authz POST-as-GET; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/challenge/{chall_id}"
|
- route: "POST /acme/profile/{id}/challenge/{chall_id}"
|
||||||
why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool."
|
why: "ACME server RFC 8555 §7.5.1 challenge response; dispatches to Phase 3 validator pool."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/cert/{cert_id}"
|
- route: "POST /acme/profile/{id}/cert/{cert_id}"
|
||||||
why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.4.2 cert download; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/new-order"
|
- route: "POST /acme/new-order"
|
||||||
why: "Phase 2 default-profile shorthand for new-order."
|
why: "Phase 2 default-profile shorthand for new-order."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/order/{ord_id}"
|
- route: "POST /acme/order/{ord_id}"
|
||||||
why: "Phase 2 default-profile shorthand for order POST-as-GET."
|
why: "Phase 2 default-profile shorthand for order POST-as-GET."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/order/{ord_id}/finalize"
|
- route: "POST /acme/order/{ord_id}/finalize"
|
||||||
why: "Phase 2 default-profile shorthand for finalize."
|
why: "Phase 2 default-profile shorthand for finalize."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/authz/{authz_id}"
|
- route: "POST /acme/authz/{authz_id}"
|
||||||
why: "Phase 2 default-profile shorthand for authz POST-as-GET."
|
why: "Phase 2 default-profile shorthand for authz POST-as-GET."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/challenge/{chall_id}"
|
- route: "POST /acme/challenge/{chall_id}"
|
||||||
why: "Phase 3 default-profile shorthand for challenge response."
|
why: "Phase 3 default-profile shorthand for challenge response."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/cert/{cert_id}"
|
- route: "POST /acme/cert/{cert_id}"
|
||||||
why: "Phase 2 default-profile shorthand for cert download."
|
why: "Phase 2 default-profile shorthand for cert download."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/key-change"
|
- route: "POST /acme/profile/{id}/key-change"
|
||||||
why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.3.5 doubly-signed key rollover; documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/profile/{id}/revoke-cert"
|
- route: "POST /acme/profile/{id}/revoke-cert"
|
||||||
why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md."
|
why: "ACME server RFC 8555 §7.6 revoke-cert (kid OR cert-key auth); documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /acme/profile/{id}/renewal-info/{cert_id}"
|
- route: "GET /acme/profile/{id}/renewal-info/{cert_id}"
|
||||||
why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md."
|
why: "ACME server RFC 9773 ACME Renewal Information (unauthenticated GET); documented in docs/acme-server.md."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/key-change"
|
- route: "POST /acme/key-change"
|
||||||
why: "Phase 4 default-profile shorthand for key rollover."
|
why: "Phase 4 default-profile shorthand for key rollover."
|
||||||
|
category: wire-protocol
|
||||||
- route: "POST /acme/revoke-cert"
|
- route: "POST /acme/revoke-cert"
|
||||||
why: "Phase 4 default-profile shorthand for revoke-cert."
|
why: "Phase 4 default-profile shorthand for revoke-cert."
|
||||||
|
category: wire-protocol
|
||||||
- route: "GET /acme/renewal-info/{cert_id}"
|
- route: "GET /acme/renewal-info/{cert_id}"
|
||||||
why: "Phase 4 default-profile shorthand for ARI."
|
why: "Phase 4 default-profile shorthand for ARI."
|
||||||
|
category: wire-protocol
|
||||||
|
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
# Auth Bundle 2 + audit-2026-05-10/11 fix bundle — REST endpoints not yet
|
# Auth Bundle 2 + audit-2026-05-10/11 fix bundle — REST endpoints not yet
|
||||||
@@ -119,59 +199,3 @@ documented_exceptions:
|
|||||||
# stays green for the v2.1.0 release tag. Threat model + handler contracts
|
# stays green for the v2.1.0 release tag. Threat model + handler contracts
|
||||||
# live in docs/operator/{rbac.md,auth-threat-model.md,oidc-runbooks/*}.
|
# live in docs/operator/{rbac.md,auth-threat-model.md,oidc-runbooks/*}.
|
||||||
# =============================================================================
|
# =============================================================================
|
||||||
- route: "GET /auth/oidc/login"
|
|
||||||
why: "Bundle 2 Phase 5 OIDC login redirect; user-facing 302 with state cookie. OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "GET /auth/oidc/callback"
|
|
||||||
why: "Bundle 2 Phase 5 OIDC callback handler; RFC 9700 §4.7.1 + RFC 9207. OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "POST /auth/logout"
|
|
||||||
why: "Bundle 2 Phase 5 cookie + CSRF revoker. OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "POST /auth/breakglass/login"
|
|
||||||
why: "Bundle 2 Phase 7.5 public break-glass login (auth-bypass, 404 when disabled). OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "POST /auth/oidc/back-channel-logout"
|
|
||||||
why: "Bundle 2 Phase 5 RFC OIDC Back-Channel Logout 1.0 endpoint. OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "GET /api/v1/auth/sessions"
|
|
||||||
why: "Bundle 2 Phase 5 self/admin session list. OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "DELETE /api/v1/auth/sessions/{id}"
|
|
||||||
why: "Bundle 2 Phase 5 session revoke. OpenAPI rep deferred to pre-2.2.0."
|
|
||||||
- route: "DELETE /api/v1/auth/sessions"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-2/3 revoke-all-except-current."
|
|
||||||
- route: "GET /api/v1/auth/oidc/providers"
|
|
||||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (list)."
|
|
||||||
- route: "POST /api/v1/auth/oidc/providers"
|
|
||||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (create)."
|
|
||||||
- route: "PUT /api/v1/auth/oidc/providers/{id}"
|
|
||||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (update)."
|
|
||||||
- route: "DELETE /api/v1/auth/oidc/providers/{id}"
|
|
||||||
why: "Bundle 2 Phase 5 OIDC provider CRUD (delete)."
|
|
||||||
- route: "POST /api/v1/auth/oidc/providers/{id}/refresh"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-7 JWKS hot-refresh."
|
|
||||||
- route: "GET /api/v1/auth/oidc/providers/{id}/jwks-status"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-7 JWKS health snapshot."
|
|
||||||
- route: "POST /api/v1/auth/oidc/test"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-5 dry-run discovery + JWKS + alg-downgrade check."
|
|
||||||
- route: "GET /api/v1/auth/oidc/group-mappings"
|
|
||||||
why: "Bundle 2 Phase 5 group-mapping CRUD (list)."
|
|
||||||
- route: "POST /api/v1/auth/oidc/group-mappings"
|
|
||||||
why: "Bundle 2 Phase 5 group-mapping CRUD (create)."
|
|
||||||
- route: "DELETE /api/v1/auth/oidc/group-mappings/{id}"
|
|
||||||
why: "Bundle 2 Phase 5 group-mapping CRUD (delete)."
|
|
||||||
- route: "GET /api/v1/auth/breakglass/credentials"
|
|
||||||
why: "Bundle 2 Phase 7.5 admin break-glass list (404 when disabled; password hash never on wire)."
|
|
||||||
- route: "POST /api/v1/auth/breakglass/credentials"
|
|
||||||
why: "Bundle 2 Phase 7.5 admin break-glass set/rotate password."
|
|
||||||
- route: "POST /api/v1/auth/breakglass/credentials/{actor_id}/unlock"
|
|
||||||
why: "Bundle 2 Phase 7.5 admin break-glass unlock after lockout."
|
|
||||||
- route: "DELETE /api/v1/auth/breakglass/credentials/{actor_id}"
|
|
||||||
why: "Bundle 2 Phase 7.5 admin break-glass credential delete."
|
|
||||||
- route: "GET /api/v1/auth/users"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-11 users page."
|
|
||||||
- route: "DELETE /api/v1/auth/users/{id}"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-11 user deactivate."
|
|
||||||
- route: "POST /api/v1/auth/users/{id}/reactivate"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-11 user reactivate."
|
|
||||||
- route: "GET /api/v1/auth/runtime-config"
|
|
||||||
why: "Bundle 2 audit-2026-05-10 MED-12 effective auth-runtime-config (read-only)."
|
|
||||||
- route: "POST /api/v1/auth/demo-residual/cleanup"
|
|
||||||
why: "Audit 2026-05-11 A-8 demo-mode residual-grants cleanup endpoint."
|
|
||||||
- route: "GET /api/v1/audit/export"
|
|
||||||
why: "Bundle 1 Phase 8 streaming NDJSON audit export."
|
|
||||||
|
|||||||
+1341
File diff suppressed because it is too large
Load Diff
+29
-6
@@ -577,7 +577,7 @@ func main() {
|
|||||||
// AuthExemptRouterRoutes path. The service-layer Argon2id lockout
|
// AuthExemptRouterRoutes path. The service-layer Argon2id lockout
|
||||||
// state machine remains the second line of defense.
|
// state machine remains the second line of defense.
|
||||||
breakglassHandler.SetLoginRateLimiter(
|
breakglassHandler.SetLoginRateLimiter(
|
||||||
ratelimit.NewSlidingWindowLimiter(5, time.Minute, 50_000),
|
ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend, db, 5, time.Minute, 50_000),
|
||||||
)
|
)
|
||||||
if cfg.Auth.Breakglass.Enabled {
|
if cfg.Auth.Breakglass.Enabled {
|
||||||
logger.Warn("CERTCTL_BREAKGLASS_ENABLED=true — break-glass admin path is ACTIVE; this bypasses SSO. Disable in steady-state.",
|
logger.Warn("CERTCTL_BREAKGLASS_ENABLED=true — break-glass admin path is ACTIVE; this bypasses SSO. Disable in steady-state.",
|
||||||
@@ -1000,7 +1000,7 @@ func main() {
|
|||||||
// Production hardening II Phase 3: per-source-IP OCSP rate limit.
|
// Production hardening II Phase 3: per-source-IP OCSP rate limit.
|
||||||
// Window 1m so the cap counts requests per minute. Map cap 50k
|
// Window 1m so the cap counts requests per minute. Map cap 50k
|
||||||
// matches the SCEP/Intune replay cache cap. Zero disables.
|
// matches the SCEP/Intune replay cache cap. Zero disables.
|
||||||
ocspLimiter := ratelimit.NewSlidingWindowLimiter(cfg.Scheduler.OCSPRateLimitPerIPMin, time.Minute, 50_000)
|
ocspLimiter := ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend, db, cfg.Scheduler.OCSPRateLimitPerIPMin, time.Minute, 50_000)
|
||||||
certificateHandler.SetOCSPRateLimiter(ocspLimiter)
|
certificateHandler.SetOCSPRateLimiter(ocspLimiter)
|
||||||
issuerHandler := handler.NewIssuerHandler(issuerService)
|
issuerHandler := handler.NewIssuerHandler(issuerService)
|
||||||
targetHandler := handler.NewTargetHandler(targetService)
|
targetHandler := handler.NewTargetHandler(targetService)
|
||||||
@@ -1065,7 +1065,7 @@ func main() {
|
|||||||
exportHandler := handler.NewExportHandler(exportService)
|
exportHandler := handler.NewExportHandler(exportService)
|
||||||
// Production hardening II Phase 3: per-actor cert-export rate limit.
|
// Production hardening II Phase 3: per-actor cert-export rate limit.
|
||||||
// Window 1h so the cap counts exports per hour. Zero disables.
|
// Window 1h so the cap counts exports per hour. Zero disables.
|
||||||
exportLimiter := ratelimit.NewSlidingWindowLimiter(cfg.Scheduler.CertExportRateLimitPerActorHr, time.Hour, 50_000)
|
exportLimiter := ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend, db, cfg.Scheduler.CertExportRateLimitPerActorHr, time.Hour, 50_000)
|
||||||
exportHandler.SetExportRateLimiter(exportLimiter)
|
exportHandler.SetExportRateLimiter(exportLimiter)
|
||||||
|
|
||||||
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
|
bulkRevocationHandler := handler.NewBulkRevocationHandler(bulkRevocationService)
|
||||||
@@ -1209,6 +1209,29 @@ func main() {
|
|||||||
sched.SetSessionGarbageCollector(sessionService)
|
sched.SetSessionGarbageCollector(sessionService)
|
||||||
sched.SetBCLReplayGarbageCollector(bclReplayRepo) // Audit 2026-05-10 HIGH-3.
|
sched.SetBCLReplayGarbageCollector(bclReplayRepo) // Audit 2026-05-10 HIGH-3.
|
||||||
sched.SetSessionGCInterval(cfg.Auth.Session.GCInterval)
|
sched.SetSessionGCInterval(cfg.Auth.Session.GCInterval)
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.3 closure (ARCH-M1): when the operator selected
|
||||||
|
// CERTCTL_RATE_LIMIT_BACKEND=postgres, wire the bucket janitor so
|
||||||
|
// stale rows from rate_limit_buckets get swept on the configured
|
||||||
|
// interval. The in-memory backend's prune-on-Allow path keeps
|
||||||
|
// buckets short-lived without a separate sweep, so we skip the
|
||||||
|
// loop entirely for backend=memory.
|
||||||
|
//
|
||||||
|
// maxWindow = 24h: the EST per-principal limiter is the longest
|
||||||
|
// window any current caller configures (the breakglass / OCSP /
|
||||||
|
// export / EST failed-basic limiters use shorter windows). Bump
|
||||||
|
// this if a new caller introduces a longer window — rows pruned
|
||||||
|
// inside their window aren't deletable.
|
||||||
|
if cfg.RateLimit.SlidingWindowBackend == "postgres" {
|
||||||
|
rateLimitGC := ratelimit.NewPostgresGC(db, 24*time.Hour)
|
||||||
|
sched.SetRateLimitGarbageCollector(rateLimitGC)
|
||||||
|
sched.SetRateLimitGCInterval(cfg.RateLimit.SlidingWindowJanitorInterval)
|
||||||
|
logger.Info("rate-limit GC sweep enabled (postgres backend)",
|
||||||
|
"interval", cfg.RateLimit.SlidingWindowJanitorInterval.String(),
|
||||||
|
"max_window", "24h")
|
||||||
|
} else {
|
||||||
|
logger.Info("rate-limit backend = memory; postgres GC sweep not wired (in-memory backend self-prunes)")
|
||||||
|
}
|
||||||
logger.Info("session GC sweep enabled",
|
logger.Info("session GC sweep enabled",
|
||||||
"interval", cfg.Auth.Session.GCInterval.String(),
|
"interval", cfg.Auth.Session.GCInterval.String(),
|
||||||
"absolute_timeout", cfg.Auth.Session.AbsoluteTimeout.String(),
|
"absolute_timeout", cfg.Auth.Session.AbsoluteTimeout.String(),
|
||||||
@@ -1532,7 +1555,7 @@ func main() {
|
|||||||
// release. The shared SlidingWindowLimiter applies the same
|
// release. The shared SlidingWindowLimiter applies the same
|
||||||
// math the SCEP/Intune limiter uses — extracted in Phase 4.1
|
// math the SCEP/Intune limiter uses — extracted in Phase 4.1
|
||||||
// of this bundle so both call sites share the implementation.
|
// of this bundle so both call sites share the implementation.
|
||||||
failed := ratelimit.NewSlidingWindowLimiter(10, time.Hour, 50_000)
|
failed := ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend, db, 10, time.Hour, 50_000)
|
||||||
estHandler.SetSourceIPRateLimiter(failed)
|
estHandler.SetSourceIPRateLimiter(failed)
|
||||||
}
|
}
|
||||||
// Phase 2.1: mTLS sibling route. When MTLSEnabled=true, build a
|
// Phase 2.1: mTLS sibling route. When MTLSEnabled=true, build a
|
||||||
@@ -1588,7 +1611,7 @@ func main() {
|
|||||||
mtlsHandler.SetChannelBindingRequired(profile.ChannelBindingRequired)
|
mtlsHandler.SetChannelBindingRequired(profile.ChannelBindingRequired)
|
||||||
mtlsHandler.SetServerKeygenEnabled(profile.ServerKeygenEnabled)
|
mtlsHandler.SetServerKeygenEnabled(profile.ServerKeygenEnabled)
|
||||||
if profile.RateLimitPerPrincipal24h > 0 {
|
if profile.RateLimitPerPrincipal24h > 0 {
|
||||||
perPrincipal := ratelimit.NewSlidingWindowLimiter(profile.RateLimitPerPrincipal24h, 24*time.Hour, 100_000)
|
perPrincipal := ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend, db, profile.RateLimitPerPrincipal24h, 24*time.Hour, 100_000)
|
||||||
mtlsHandler.SetPerPrincipalRateLimiter(perPrincipal)
|
mtlsHandler.SetPerPrincipalRateLimiter(perPrincipal)
|
||||||
}
|
}
|
||||||
estMTLSHandlers[profile.PathID] = mtlsHandler
|
estMTLSHandlers[profile.PathID] = mtlsHandler
|
||||||
@@ -1610,7 +1633,7 @@ func main() {
|
|||||||
// when configured). The mTLS handler above gets its own
|
// when configured). The mTLS handler above gets its own
|
||||||
// limiter instance so the two routes don't share a bucket.
|
// limiter instance so the two routes don't share a bucket.
|
||||||
if profile.RateLimitPerPrincipal24h > 0 {
|
if profile.RateLimitPerPrincipal24h > 0 {
|
||||||
perPrincipal := ratelimit.NewSlidingWindowLimiter(profile.RateLimitPerPrincipal24h, 24*time.Hour, 100_000)
|
perPrincipal := ratelimit.NewLimiter(cfg.RateLimit.SlidingWindowBackend, db, profile.RateLimitPerPrincipal24h, 24*time.Hour, 100_000)
|
||||||
estHandler.SetPerPrincipalRateLimiter(perPrincipal)
|
estHandler.SetPerPrincipalRateLimiter(perPrincipal)
|
||||||
}
|
}
|
||||||
estHandlers[profile.PathID] = estHandler
|
estHandlers[profile.PathID] = estHandler
|
||||||
|
|||||||
@@ -12,6 +12,8 @@ data:
|
|||||||
keygen-mode: {{ .Values.server.keygen.mode | quote }}
|
keygen-mode: {{ .Values.server.keygen.mode | quote }}
|
||||||
rate-limit-rps: {{ .Values.server.rateLimiting.rps | quote }}
|
rate-limit-rps: {{ .Values.server.rateLimiting.rps | quote }}
|
||||||
rate-limit-burst: {{ .Values.server.rateLimiting.burst | quote }}
|
rate-limit-burst: {{ .Values.server.rateLimiting.burst | quote }}
|
||||||
|
rate-limit-backend: {{ .Values.server.rateLimiting.backend | default "memory" | quote }}
|
||||||
|
rate-limit-janitor-interval: {{ .Values.server.rateLimiting.janitorInterval | default "5m" | quote }}
|
||||||
{{- if .Values.server.cors.origins }}
|
{{- if .Values.server.cors.origins }}
|
||||||
cors-origins: {{ .Values.server.cors.origins | quote }}
|
cors-origins: {{ .Values.server.cors.origins | quote }}
|
||||||
{{- end }}
|
{{- end }}
|
||||||
|
|||||||
@@ -108,6 +108,19 @@ spec:
|
|||||||
configMapKeyRef:
|
configMapKeyRef:
|
||||||
name: {{ include "certctl.fullname" . }}-server
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
key: rate-limit-burst
|
key: rate-limit-burst
|
||||||
|
# Phase 13 Sprint 13.3 (ARCH-M1) — cross-replica-consistent
|
||||||
|
# sliding-window rate limiter. Default memory; flip to
|
||||||
|
# postgres when server.replicas > 1.
|
||||||
|
- name: CERTCTL_RATE_LIMIT_BACKEND
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: rate-limit-backend
|
||||||
|
- name: CERTCTL_RATE_LIMIT_JANITOR_INTERVAL
|
||||||
|
valueFrom:
|
||||||
|
configMapKeyRef:
|
||||||
|
name: {{ include "certctl.fullname" . }}-server
|
||||||
|
key: rate-limit-janitor-interval
|
||||||
{{- if .Values.server.cors.origins }}
|
{{- if .Values.server.cors.origins }}
|
||||||
- name: CERTCTL_CORS_ORIGINS
|
- name: CERTCTL_CORS_ORIGINS
|
||||||
valueFrom:
|
valueFrom:
|
||||||
|
|||||||
@@ -211,8 +211,25 @@ server:
|
|||||||
|
|
||||||
# Rate limiting configuration
|
# Rate limiting configuration
|
||||||
rateLimiting:
|
rateLimiting:
|
||||||
rps: 100 # Requests per second
|
rps: 100 # Requests per second (token-bucket middleware)
|
||||||
burst: 200 # Burst capacity
|
burst: 200 # Burst capacity (token-bucket middleware)
|
||||||
|
|
||||||
|
# Sliding-window-log rate-limit backend (Phase 13 Sprint 13.2/13.3
|
||||||
|
# ARCH-M1 closure). Selects the implementation backing the
|
||||||
|
# break-glass / OCSP / cert-export / EST limiters. See
|
||||||
|
# docs/operator/observability.md for the operator decision tree.
|
||||||
|
#
|
||||||
|
# memory — per-process (default; single-replica deploys).
|
||||||
|
# postgres — cross-replica-consistent via rate_limit_buckets.
|
||||||
|
# REQUIRED when server.replicas > 1 for accurate
|
||||||
|
# cluster-wide enforcement.
|
||||||
|
backend: memory
|
||||||
|
|
||||||
|
# Scheduler janitor interval for the postgres backend's
|
||||||
|
# rate_limit_buckets sweep. Ignored when backend=memory (the
|
||||||
|
# in-memory backend self-prunes on every Allow call).
|
||||||
|
# Default 5m; minimum 1m.
|
||||||
|
janitorInterval: "5m"
|
||||||
|
|
||||||
# Network scanning configuration
|
# Network scanning configuration
|
||||||
networkScan:
|
networkScan:
|
||||||
|
|||||||
+128
-38
@@ -121,52 +121,142 @@ explicitly scrubs the password before it reaches the audit subsystem
|
|||||||
(see [`docs/operator/auth-threat-model.md`](auth-threat-model.md) §
|
(see [`docs/operator/auth-threat-model.md`](auth-threat-model.md) §
|
||||||
"Break-glass token leak").
|
"Break-glass token leak").
|
||||||
|
|
||||||
## Rate-limit behavior under restarts and replicas
|
## Rate-limit behavior — configurable backend (memory or postgres)
|
||||||
|
|
||||||
Where rate limits exist, they are **per-process, in-memory,
|
The sliding-window-log rate limiters used across certctl's
|
||||||
reset-on-restart, and not shared across replicas**. This matters for
|
authenticated-but-shared-credential code paths (break-glass login,
|
||||||
multi-replica deployments and for any compliance posture that asks
|
OCSP per-IP, cert-export per-actor, EST per-principal, EST
|
||||||
"what limits apply globally vs per-pod."
|
failed-basic source-IP) carry a **configurable backend**. The
|
||||||
|
operator picks between two implementations via
|
||||||
|
`CERTCTL_RATE_LIMIT_BACKEND`:
|
||||||
|
|
||||||
|
| Value | When to use |
|
||||||
|
|------------|------------------------------------------------------|
|
||||||
|
| `memory` | Default. Single-replica deploys; sketchpad / dev. |
|
||||||
|
| `postgres` | HA deploys (`server.replicas > 1`). Cross-replica-consistent. |
|
||||||
|
|
||||||
|
Phase 13 Sprint 13.2/13.3 (architecture diligence audit ARCH-M1
|
||||||
|
closure) replaced the prior single-process limitation with a
|
||||||
|
substantive close: when the operator opts into `postgres`, all
|
||||||
|
replicas share the same
|
||||||
|
`rate_limit_buckets` table (migration 000046) and per-key access is
|
||||||
|
arbitrated via `SELECT FOR UPDATE` row locks. A 3-replica cluster
|
||||||
|
hitting one rate-limited endpoint concurrently sees exactly the
|
||||||
|
configured cap succeed across the cluster — not 3× the cap as the
|
||||||
|
old per-process backend would have allowed.
|
||||||
|
|
||||||
|
### Operator decision tree
|
||||||
|
|
||||||
|
```
|
||||||
|
Single replica (server.replicas = 1, the helm chart default)?
|
||||||
|
└─ Use CERTCTL_RATE_LIMIT_BACKEND=memory (the default; no action
|
||||||
|
required). Bucket lookups stay in-process; zero DB round-trips
|
||||||
|
on the hot path.
|
||||||
|
|
||||||
|
Two or more replicas?
|
||||||
|
└─ Use CERTCTL_RATE_LIMIT_BACKEND=postgres. Two extra DB round-trips
|
||||||
|
per Allow call (BEGIN ... SELECT FOR UPDATE ... UPDATE ... COMMIT);
|
||||||
|
acceptable on the gated hot path. The Sprint 13.2 multi-replica
|
||||||
|
integration test pins exactly-cap enforcement across N replicas
|
||||||
|
as the closure proof.
|
||||||
|
```
|
||||||
|
|
||||||
### Inventory
|
### Inventory
|
||||||
|
|
||||||
| Limiter | Scope | Window | Cap | Survives restart? | Shared across replicas? |
|
| Limiter | Scope | Window | Cap |
|
||||||
|---|---|---|---|---|---|
|
|---|---|---|---|
|
||||||
| Break-glass login (per source-IP) | `internal/api/handler/auth_breakglass.go` | 60s | 5 attempts | No | No |
|
| Break-glass login (per source-IP) | `internal/api/handler/auth_breakglass.go` | 60s | 5 attempts |
|
||||||
| SCEP/Intune per-device challenge | `internal/scep/intune/` | 60s | configurable (`*_PER_MINUTE`) | No | No |
|
| OCSP query (per source-IP) | `internal/api/handler/certificates.go` | 60s | configurable (`CERTCTL_OCSP_RATE_LIMIT_PER_IP_MIN`) |
|
||||||
| EST per-principal CSR enrollment | `internal/est/` | 60s | configurable | No | No |
|
| Cert export (per actor) | `internal/api/handler/export.go` | 1h | configurable (`CERTCTL_CERT_EXPORT_RATE_LIMIT_PER_ACTOR_HR`) |
|
||||||
| EST HTTP-Basic source-IP failed-auth | `internal/est/` | 60s | configurable | No | No |
|
| EST per-principal CSR enrollment | `internal/api/handler/est.go` | 24h | configurable (per-profile `RateLimitPerPrincipal24h`) |
|
||||||
| ACME per-account orders / key-change / challenge-respond | `internal/service/acme.go` | 1h | configurable | No | No |
|
| EST HTTP-Basic source-IP failed-auth | `internal/api/handler/est.go` | 60m | 10 attempts |
|
||||||
|
| SCEP/Intune per-device challenge | `internal/scep/intune/` | 60s | configurable (`*_PER_MINUTE`) |
|
||||||
|
| ACME per-account orders / key-change / challenge-respond | `internal/service/acme.go` | 1h | configurable |
|
||||||
|
|
||||||
All five use the shared `internal/ratelimit/sliding_window.go`
|
The `CERTCTL_RATE_LIMIT_BACKEND` selector applies to the first five
|
||||||
primitive. Buckets live in a single per-process map guarded by a
|
(the cmd/server-wired limiters). The SCEP/Intune wrapper + the ACME
|
||||||
mutex; the package-level cap prevents unbounded growth under
|
per-account limiter ride their own internal accounting today; both
|
||||||
adversarial key cardinality (default 100,000 keys; oldest-by-newest-
|
are tracked as follow-ups in WORKSPACE-ROADMAP.md.
|
||||||
timestamp evicted under pressure).
|
|
||||||
|
|
||||||
### Implications for multi-replica deployments
|
### Backend internals
|
||||||
|
|
||||||
- **Effective per-replica cap is the documented cap.** A 2-replica
|
Both backends share the algorithm: sliding-window log + per-key
|
||||||
deployment lets through up to 2× the per-key window cap before
|
bucket + prune-on-Allow.
|
||||||
either replica rejects.
|
|
||||||
- **Restart resets the bucket.** A `kubectl rollout restart` empties
|
|
||||||
the in-memory windows on every replica. An attacker who notices
|
|
||||||
this could in principle re-issue burst attempts after every roll;
|
|
||||||
the threat model accepts this because rollouts are operator-driven
|
|
||||||
and the relevant endpoints already require credentials.
|
|
||||||
- **No cross-replica fan-out.** Rate-limit decisions on replica A
|
|
||||||
are not visible to replica B. Sticky-session ingress routing (with
|
|
||||||
`service.spec.sessionAffinity: ClientIP` on Kubernetes or the
|
|
||||||
equivalent on your load balancer) tightens the effective cap to
|
|
||||||
per-replica + per-source-IP rather than per-replica + per-source-IP
|
|
||||||
for whichever pod the request happened to land on.
|
|
||||||
|
|
||||||
If your threat model requires globally-enforced rate limits across
|
**Memory backend (`memory`)** — per-process map keyed by bucket key;
|
||||||
replicas, the implementation surface is roughly: swap the per-process
|
mutex-guarded; package-level LRU cap prevents unbounded growth under
|
||||||
map for a database-backed sliding window (or a Redis-backed equivalent
|
adversarial key cardinality (default 100,000 keys per limiter
|
||||||
if you already run Redis). This is on the
|
instance; oldest-by-newest-timestamp evicted under pressure).
|
||||||
[WORKSPACE-ROADMAP.md](../../WORKSPACE-ROADMAP.md) as a v3 item;
|
Implemented at `internal/ratelimit/sliding_window.go`.
|
||||||
nothing in the certctl threat model today requires it.
|
|
||||||
|
**Postgres backend (`postgres`)** — same algorithm against the
|
||||||
|
`rate_limit_buckets` table:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE rate_limit_buckets (
|
||||||
|
bucket_key TEXT PRIMARY KEY,
|
||||||
|
timestamps TIMESTAMPTZ[] NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
`Allow(key, now)` opens a transaction, ensures the row exists
|
||||||
|
(`INSERT ... ON CONFLICT DO NOTHING`), acquires the row lock
|
||||||
|
(`SELECT ... FOR UPDATE`), prunes timestamps older than `now-window`,
|
||||||
|
compares the post-prune count against `maxN`, conditionally appends
|
||||||
|
`now`, persists, and commits. The row lock is what arbitrates across
|
||||||
|
replicas: replicas A and B firing simultaneous `Allow("k")` never
|
||||||
|
race because Postgres serializes the per-key row update across the
|
||||||
|
cluster. Implemented at
|
||||||
|
`internal/ratelimit/postgres_sliding_window.go`.
|
||||||
|
|
||||||
|
### Janitor sweep (postgres backend only)
|
||||||
|
|
||||||
|
The scheduler runs a `rate_limit_buckets` janitor every
|
||||||
|
`CERTCTL_RATE_LIMIT_JANITOR_INTERVAL` (default 5m, minimum 1m). The
|
||||||
|
sweep deletes rows whose `updated_at` is older than the longest
|
||||||
|
configured window any limiter uses (24h today, matching the EST
|
||||||
|
per-principal limiter). Idempotent; repeated sweeps find zero rows.
|
||||||
|
The memory backend's prune-on-Allow path keeps buckets short-lived
|
||||||
|
without a separate sweep, so the loop is a no-op when
|
||||||
|
`backend=memory`.
|
||||||
|
|
||||||
|
### Falsifiable closure proof
|
||||||
|
|
||||||
|
The Phase 13 Sprint 13.2 integration test
|
||||||
|
`internal/integration/ratelimit_multi_replica_test.go`
|
||||||
|
(`//go:build integration`) fires 100 concurrent `Allow("test-key")`
|
||||||
|
calls round-robined across 3 independent `PostgresSlidingWindowLimiter`
|
||||||
|
instances sharing one Postgres database (`cap=10`, `window=1m`) and
|
||||||
|
asserts exactly 10 succeed + 90 return `ErrRateLimited`. If the
|
||||||
|
cross-replica row lock weren't arbitrating, each replica would
|
||||||
|
independently let through ~3-4 requests, giving 12-15 successes
|
||||||
|
total. Re-run:
|
||||||
|
|
||||||
|
```
|
||||||
|
go test -tags=integration -count=1 -run TestRateLimit_MultiReplica \
|
||||||
|
./internal/integration/...
|
||||||
|
```
|
||||||
|
|
||||||
|
### Helm chart wiring
|
||||||
|
|
||||||
|
The helm chart at `deploy/helm/certctl/` exposes the backend via
|
||||||
|
`server.rateLimiting.backend` (default `memory`). To opt into the
|
||||||
|
postgres backend for an HA deploy:
|
||||||
|
|
||||||
|
```
|
||||||
|
helm upgrade --install certctl deploy/helm/certctl \
|
||||||
|
--set server.replicas=3 \
|
||||||
|
--set server.rateLimiting.backend=postgres \
|
||||||
|
--set server.rateLimiting.janitorInterval=5m
|
||||||
|
```
|
||||||
|
|
||||||
|
`server.replicas > 1` without flipping `backend` to `postgres` works
|
||||||
|
fine — the limits stay per-process — but the operator gets a 2× /
|
||||||
|
3× / Nx effective cap depending on replica count. The chart does NOT
|
||||||
|
auto-flip on `replicas > 1` because some HA deploys deliberately want
|
||||||
|
per-process limits (sticky-session ingress + tight per-replica caps
|
||||||
|
to detect bot traffic at the edge before it hits the application).
|
||||||
|
|
||||||
### Where these numbers live
|
### Where these numbers live
|
||||||
|
|
||||||
|
|||||||
@@ -4,12 +4,12 @@
|
|||||||
<!-- Re-run after adding or removing any t.Skip(). CI guard: -->
|
<!-- Re-run after adding or removing any t.Skip(). CI guard: -->
|
||||||
<!-- scripts/ci-guards/skip-inventory-drift.sh -->
|
<!-- scripts/ci-guards/skip-inventory-drift.sh -->
|
||||||
|
|
||||||
> Last reviewed: 2026-05-13
|
> Last reviewed: 2026-05-14
|
||||||
|
|
||||||
## Summary
|
## Summary
|
||||||
|
|
||||||
- Total t.Skip sites: **142**
|
- Total t.Skip sites: **144**
|
||||||
- testing.Short() guards: **76** (these gate behind `go test -short`)
|
- testing.Short() guards: **78** (these gate behind `go test -short`)
|
||||||
|
|
||||||
Re-run inventory with: `./scripts/skip-inventory.sh`.
|
Re-run inventory with: `./scripts/skip-inventory.sh`.
|
||||||
|
|
||||||
@@ -156,6 +156,8 @@ Re-run inventory with: `./scripts/skip-inventory.sh`.
|
|||||||
|
|
||||||
### `internal/ratelimit`
|
### `internal/ratelimit`
|
||||||
|
|
||||||
|
- `internal/ratelimit/equivalence_test.go:80` — t.Skip("race-style test under -short")
|
||||||
|
- `internal/ratelimit/equivalence_test.go:88` — t.Skip("postgres equivalence tests require testcontainers; skipped under -short")
|
||||||
- `internal/ratelimit/sliding_window_test.go:146` — t.Skip("race-style test under -short")
|
- `internal/ratelimit/sliding_window_test.go:146` — t.Skip("race-style test under -short")
|
||||||
|
|
||||||
### `internal/repository/postgres`
|
### `internal/repository/postgres`
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ type AuthBreakglassHandler struct {
|
|||||||
// nil-safe: when unset, the handler skips the limiter check and
|
// nil-safe: when unset, the handler skips the limiter check and
|
||||||
// relies on the service-layer Argon2id lockout. Production deploys
|
// relies on the service-layer Argon2id lockout. Production deploys
|
||||||
// MUST set this via SetLoginRateLimiter.
|
// MUST set this via SetLoginRateLimiter.
|
||||||
loginLimiter *ratelimit.SlidingWindowLimiter
|
loginLimiter ratelimit.Limiter
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewAuthBreakglassHandler constructs the handler.
|
// NewAuthBreakglassHandler constructs the handler.
|
||||||
@@ -89,7 +89,7 @@ func NewAuthBreakglassHandler(svc BreakglassService, cookieAttrs SessionCookieAt
|
|||||||
// SetLoginRateLimiter wires the per-source-IP rate limiter the Login
|
// SetLoginRateLimiter wires the per-source-IP rate limiter the Login
|
||||||
// handler enforces. Bundle 5 closure (S1) — see the AuthBreakglassHandler
|
// handler enforces. Bundle 5 closure (S1) — see the AuthBreakglassHandler
|
||||||
// type docstring for the full rationale.
|
// type docstring for the full rationale.
|
||||||
func (h *AuthBreakglassHandler) SetLoginRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
func (h *AuthBreakglassHandler) SetLoginRateLimiter(l ratelimit.Limiter) {
|
||||||
h.loginLimiter = l
|
h.loginLimiter = l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ type CertificateService interface {
|
|||||||
// CertificateHandler handles HTTP requests for certificate operations.
|
// CertificateHandler handles HTTP requests for certificate operations.
|
||||||
type CertificateHandler struct {
|
type CertificateHandler struct {
|
||||||
svc CertificateService
|
svc CertificateService
|
||||||
ocspLimiter *ratelimit.SlidingWindowLimiter // production hardening II Phase 3 — per-source-IP cap on OCSP
|
ocspLimiter ratelimit.Limiter // production hardening II Phase 3 — per-source-IP cap on OCSP
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewCertificateHandler creates a new CertificateHandler with a service dependency.
|
// NewCertificateHandler creates a new CertificateHandler with a service dependency.
|
||||||
@@ -65,7 +65,7 @@ func NewCertificateHandler(svc CertificateService) CertificateHandler {
|
|||||||
// cmd/server/main.go): 1000 req/min/IP. Setting to nil disables the
|
// cmd/server/main.go): 1000 req/min/IP. Setting to nil disables the
|
||||||
// limit; the limiter's own NewSlidingWindowLimiter(maxN<=0, ...)
|
// limit; the limiter's own NewSlidingWindowLimiter(maxN<=0, ...)
|
||||||
// also produces a no-op limiter, so the env-var-zero case is safe.
|
// also produces a no-op limiter, so the env-var-zero case is safe.
|
||||||
func (h *CertificateHandler) SetOCSPRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
func (h *CertificateHandler) SetOCSPRateLimiter(l ratelimit.Limiter) {
|
||||||
h.ocspLimiter = l
|
h.ocspLimiter = l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -100,13 +100,13 @@ type ESTHandler struct {
|
|||||||
// EST RFC 7030 hardening Phase 3.3: per-handler source-IP rate
|
// EST RFC 7030 hardening Phase 3.3: per-handler source-IP rate
|
||||||
// limiter for FAILED HTTP Basic auth attempts. Keyed by sourceIP so
|
// limiter for FAILED HTTP Basic auth attempts. Keyed by sourceIP so
|
||||||
// a hostile network segment can't burn through the password.
|
// a hostile network segment can't burn through the password.
|
||||||
failedBasicLimiter *ratelimit.SlidingWindowLimiter
|
failedBasicLimiter ratelimit.Limiter
|
||||||
|
|
||||||
// EST RFC 7030 hardening Phase 4.2: per-handler per-principal sliding-
|
// EST RFC 7030 hardening Phase 4.2: per-handler per-principal sliding-
|
||||||
// window rate limit. Keyed by (CSR-CN, sourceIP) so a stolen
|
// window rate limit. Keyed by (CSR-CN, sourceIP) so a stolen
|
||||||
// bootstrap cert AND a known device CN can't be used to flood the
|
// bootstrap cert AND a known device CN can't be used to flood the
|
||||||
// issuer. Disabled when nil; configured per-profile.
|
// issuer. Disabled when nil; configured per-profile.
|
||||||
perPrincipalLimiter *ratelimit.SlidingWindowLimiter
|
perPrincipalLimiter ratelimit.Limiter
|
||||||
|
|
||||||
// labelForLog gives observability code a per-profile string to
|
// labelForLog gives observability code a per-profile string to
|
||||||
// include in audit log lines / Prometheus labels. Defaults to
|
// include in audit log lines / Prometheus labels. Defaults to
|
||||||
@@ -170,7 +170,7 @@ func (h *ESTHandler) SetEnrollmentPassword(pw string) { h.basicPassword = pw }
|
|||||||
// rate limiter. Phase 3.3. Disabled when nil — but Validate() at
|
// rate limiter. Phase 3.3. Disabled when nil — but Validate() at
|
||||||
// startup refuses an enabled basic-auth profile without a configured
|
// startup refuses an enabled basic-auth profile without a configured
|
||||||
// limiter, so a real deploy always wires one.
|
// limiter, so a real deploy always wires one.
|
||||||
func (h *ESTHandler) SetSourceIPRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
func (h *ESTHandler) SetSourceIPRateLimiter(l ratelimit.Limiter) {
|
||||||
h.failedBasicLimiter = l
|
h.failedBasicLimiter = l
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -179,7 +179,7 @@ func (h *ESTHandler) SetSourceIPRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
|||||||
// every successful enrollment, NOT just failures — the goal is to
|
// every successful enrollment, NOT just failures — the goal is to
|
||||||
// bound enrollment-flooding from a compromised credential, not just
|
// bound enrollment-flooding from a compromised credential, not just
|
||||||
// failed-auth brute force.
|
// failed-auth brute force.
|
||||||
func (h *ESTHandler) SetPerPrincipalRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
func (h *ESTHandler) SetPerPrincipalRateLimiter(l ratelimit.Limiter) {
|
||||||
h.perPrincipalLimiter = l
|
h.perPrincipalLimiter = l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type ExportService interface {
|
|||||||
// ExportHandler handles HTTP requests for certificate export operations.
|
// ExportHandler handles HTTP requests for certificate export operations.
|
||||||
type ExportHandler struct {
|
type ExportHandler struct {
|
||||||
svc ExportService
|
svc ExportService
|
||||||
exportLimiter *ratelimit.SlidingWindowLimiter // production hardening II Phase 3
|
exportLimiter ratelimit.Limiter // production hardening II Phase 3
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewExportHandler creates a new ExportHandler with a service dependency.
|
// NewExportHandler creates a new ExportHandler with a service dependency.
|
||||||
@@ -40,7 +40,7 @@ func NewExportHandler(svc ExportService) ExportHandler {
|
|||||||
// Production hardening II Phase 3. Default cap (when set in
|
// Production hardening II Phase 3. Default cap (when set in
|
||||||
// cmd/server/main.go): 50 exports/hr/operator. Setting to nil
|
// cmd/server/main.go): 50 exports/hr/operator. Setting to nil
|
||||||
// disables the limit.
|
// disables the limit.
|
||||||
func (h *ExportHandler) SetExportRateLimiter(l *ratelimit.SlidingWindowLimiter) {
|
func (h *ExportHandler) SetExportRateLimiter(l ratelimit.Limiter) {
|
||||||
h.exportLimiter = l
|
h.exportLimiter = l
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -441,11 +441,13 @@ func Load() (*Config, error) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
RateLimit: RateLimitConfig{
|
RateLimit: RateLimitConfig{
|
||||||
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
|
Enabled: getEnvBool("CERTCTL_RATE_LIMIT_ENABLED", true),
|
||||||
RPS: getEnvFloat("CERTCTL_RATE_LIMIT_RPS", 50),
|
RPS: getEnvFloat("CERTCTL_RATE_LIMIT_RPS", 50),
|
||||||
BurstSize: getEnvInt("CERTCTL_RATE_LIMIT_BURST", 100),
|
BurstSize: getEnvInt("CERTCTL_RATE_LIMIT_BURST", 100),
|
||||||
PerUserRPS: getEnvFloat("CERTCTL_RATE_LIMIT_PER_USER_RPS", 0),
|
PerUserRPS: getEnvFloat("CERTCTL_RATE_LIMIT_PER_USER_RPS", 0),
|
||||||
PerUserBurstSize: getEnvInt("CERTCTL_RATE_LIMIT_PER_USER_BURST", 0),
|
PerUserBurstSize: getEnvInt("CERTCTL_RATE_LIMIT_PER_USER_BURST", 0),
|
||||||
|
SlidingWindowBackend: getEnv("CERTCTL_RATE_LIMIT_BACKEND", "memory"),
|
||||||
|
SlidingWindowJanitorInterval: getEnvDuration("CERTCTL_RATE_LIMIT_JANITOR_INTERVAL", 5*time.Minute),
|
||||||
},
|
},
|
||||||
CORS: CORSConfig{
|
CORS: CORSConfig{
|
||||||
AllowedOrigins: getEnvList("CERTCTL_CORS_ORIGINS", nil),
|
AllowedOrigins: getEnvList("CERTCTL_CORS_ORIGINS", nil),
|
||||||
@@ -764,6 +766,36 @@ func (c *Config) Validate() error {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.3 closure (ARCH-M1): validate
|
||||||
|
// CERTCTL_RATE_LIMIT_BACKEND is one of the two supported values.
|
||||||
|
// Fail-closed on any other input so a typo doesn't silently fall
|
||||||
|
// back to the wrong backend (the operator picked "postgress" and
|
||||||
|
// got memory rate-limits in a 3-replica cluster).
|
||||||
|
switch c.RateLimit.SlidingWindowBackend {
|
||||||
|
case "", "memory", "postgres":
|
||||||
|
// "" is treated as "memory" — test-built Configs (which
|
||||||
|
// construct the struct literal directly without going
|
||||||
|
// through Load()) don't get the default; Load() always
|
||||||
|
// fills "memory". Either path lands the runtime on the
|
||||||
|
// in-memory backend.
|
||||||
|
default:
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid CERTCTL_RATE_LIMIT_BACKEND=%q — refuse to start: must be \"memory\" (default, per-process limits; for single-replica deploys) or \"postgres\" (cross-replica-consistent via the rate_limit_buckets table; required for HA deploys). See docs/operator/observability.md.",
|
||||||
|
c.RateLimit.SlidingWindowBackend,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
// Janitor interval lower bound — 1 minute. Below this the sweep
|
||||||
|
// cost outweighs the row-cleanup benefit; above this still
|
||||||
|
// matches the operator's bound (5 minutes default; can be raised
|
||||||
|
// indefinitely).
|
||||||
|
if c.RateLimit.SlidingWindowJanitorInterval > 0 &&
|
||||||
|
c.RateLimit.SlidingWindowJanitorInterval < time.Minute {
|
||||||
|
return fmt.Errorf(
|
||||||
|
"invalid CERTCTL_RATE_LIMIT_JANITOR_INTERVAL=%v — refuse to start: must be ≥ 1 minute (default 5m).",
|
||||||
|
c.RateLimit.SlidingWindowJanitorInterval,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// Validate database configuration
|
// Validate database configuration
|
||||||
if c.Database.URL == "" {
|
if c.Database.URL == "" {
|
||||||
return fmt.Errorf("database URL is required")
|
return fmt.Errorf("database URL is required")
|
||||||
|
|||||||
@@ -321,6 +321,46 @@ type RateLimitConfig struct {
|
|||||||
// zero, BurstSize is used. Default: 0 (use BurstSize).
|
// zero, BurstSize is used. Default: 0 (use BurstSize).
|
||||||
// Setting: CERTCTL_RATE_LIMIT_PER_USER_BURST environment variable.
|
// Setting: CERTCTL_RATE_LIMIT_PER_USER_BURST environment variable.
|
||||||
PerUserBurstSize int
|
PerUserBurstSize int
|
||||||
|
|
||||||
|
// SlidingWindowBackend selects which backend implements the
|
||||||
|
// per-key sliding-window-log limiters wired in cmd/server/main.go
|
||||||
|
// (break-glass login, OCSP per-IP, cert-export per-actor, EST
|
||||||
|
// per-principal, EST failed-basic source-IP). Distinct from the
|
||||||
|
// token-bucket fields above — those are middleware RPS limits
|
||||||
|
// applied across every request via the http handler chain; this
|
||||||
|
// field controls the sliding-window-log primitive used by
|
||||||
|
// authenticated-but-shared-credential code paths.
|
||||||
|
//
|
||||||
|
// Valid values:
|
||||||
|
// "memory" — per-process, sync.Mutex-guarded map (historical
|
||||||
|
// default; perfect for single-replica deploys).
|
||||||
|
// "postgres" — cross-replica-consistent via the
|
||||||
|
// rate_limit_buckets table (migration 000046).
|
||||||
|
// SELECT FOR UPDATE arbitrates per-key access
|
||||||
|
// across the cluster. Adds ~2 DB round-trips per
|
||||||
|
// Allow call; acceptable on the gated hot path.
|
||||||
|
//
|
||||||
|
// Default: "memory". HA deploys with server.replicas > 1 should
|
||||||
|
// flip to "postgres" so a 2-replica deployment doesn't effectively
|
||||||
|
// double the per-key cap.
|
||||||
|
//
|
||||||
|
// Phase 13 Sprint 13.2/13.3 closure (architecture diligence audit
|
||||||
|
// ARCH-M1). See docs/operator/observability.md.
|
||||||
|
//
|
||||||
|
// Setting: CERTCTL_RATE_LIMIT_BACKEND environment variable.
|
||||||
|
SlidingWindowBackend string
|
||||||
|
|
||||||
|
// SlidingWindowJanitorInterval is how often the scheduler sweeps
|
||||||
|
// stale rows from rate_limit_buckets. A row is stale when its
|
||||||
|
// updated_at is older than the longest configured window any
|
||||||
|
// caller uses (currently 24h for the EST per-principal limiter).
|
||||||
|
// Default: 5 minutes. Minimum: 1 minute. No-op when
|
||||||
|
// SlidingWindowBackend = "memory" (the in-memory backend's
|
||||||
|
// prune-on-Allow path keeps buckets short-lived without a
|
||||||
|
// separate sweep).
|
||||||
|
//
|
||||||
|
// Setting: CERTCTL_RATE_LIMIT_JANITOR_INTERVAL environment variable.
|
||||||
|
SlidingWindowJanitorInterval time.Duration
|
||||||
}
|
}
|
||||||
|
|
||||||
// CORSConfig contains CORS configuration.
|
// CORSConfig contains CORS configuration.
|
||||||
|
|||||||
@@ -0,0 +1,195 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
//go:build integration
|
||||||
|
|
||||||
|
package integration
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"sync/atomic"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
|
||||||
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.2 closure (2026-05-14, architecture diligence audit
|
||||||
|
// ARCH-M1) — the falsifiable closure proof for cross-replica rate-limit
|
||||||
|
// consistency.
|
||||||
|
//
|
||||||
|
// Scenario:
|
||||||
|
// - ONE postgres container (representing the shared backend).
|
||||||
|
// - N=3 independent *PostgresSlidingWindowLimiter instances pointing
|
||||||
|
// at it (representing 3 server replicas — each replica's process
|
||||||
|
// has its own constructed limiter, but they all share the same
|
||||||
|
// database state).
|
||||||
|
// - 100 concurrent Allow("test-key") calls spread across the 3
|
||||||
|
// limiters via sync.WaitGroup.
|
||||||
|
// - Assert: exactly 10 succeed + 90 return ErrRateLimited.
|
||||||
|
//
|
||||||
|
// If the postgres backend's SELECT FOR UPDATE serialization weren't
|
||||||
|
// arbitrating across the 3 limiters, more than 10 calls would be
|
||||||
|
// allowed (each replica would independently let through 10/3 ≈ 4
|
||||||
|
// requests, giving ~12-15 successes depending on scheduling). The
|
||||||
|
// hard-pass on exactly-10 is what makes ARCH-M1 closure substantive
|
||||||
|
// rather than wishful.
|
||||||
|
//
|
||||||
|
// Gated by //go:build integration matching the rest of
|
||||||
|
// internal/integration/. Sprint 13.3 promotes this test to a
|
||||||
|
// required CI status check.
|
||||||
|
|
||||||
|
func TestRateLimit_PostgresBackend_CapEnforcedAcrossReplicas(t *testing.T) {
|
||||||
|
const (
|
||||||
|
replicas = 3
|
||||||
|
cap = 10
|
||||||
|
window = 1 * time.Minute
|
||||||
|
concurrentReq = 100
|
||||||
|
key = "test-key"
|
||||||
|
)
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// Boot a shared postgres container.
|
||||||
|
container, dsn := startPostgresContainer(ctx, t)
|
||||||
|
t.Cleanup(func() { _ = container.Terminate(context.Background()) })
|
||||||
|
|
||||||
|
// Each "replica" gets its own *sql.DB pool — same database, different
|
||||||
|
// connection pool — matching how N server processes would each open
|
||||||
|
// their own pool to the same control-plane database.
|
||||||
|
dbs := make([]*sql.DB, replicas)
|
||||||
|
for i := 0; i < replicas; i++ {
|
||||||
|
db, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db (replica %d): %v", i, err)
|
||||||
|
}
|
||||||
|
db.SetMaxOpenConns(8)
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
t.Fatalf("ping (replica %d): %v", i, err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { db.Close() })
|
||||||
|
dbs[i] = db
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply the rate_limit_buckets migration via dbs[0]. All replicas
|
||||||
|
// see the same schema since they share the same database.
|
||||||
|
migPath := findMigrationFromHere("000046_rate_limit_buckets.up.sql")
|
||||||
|
body, err := os.ReadFile(migPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := dbs[0].ExecContext(ctx, string(body)); err != nil {
|
||||||
|
t.Fatalf("apply migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Instantiate one limiter per replica.
|
||||||
|
limiters := make([]*ratelimit.PostgresSlidingWindowLimiter, replicas)
|
||||||
|
for i := 0; i < replicas; i++ {
|
||||||
|
limiters[i] = ratelimit.NewPostgresSlidingWindowLimiter(dbs[i], cap, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fire concurrentReq parallel Allow calls, round-robining across the
|
||||||
|
// replicas. Each call uses the SAME key + a SHARED `now` so the
|
||||||
|
// scenario is deterministic. The cross-replica row lock is what
|
||||||
|
// enforces the cap globally.
|
||||||
|
var (
|
||||||
|
allowed int64
|
||||||
|
denied int64
|
||||||
|
wg sync.WaitGroup
|
||||||
|
)
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < concurrentReq; i++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(idx int) {
|
||||||
|
defer wg.Done()
|
||||||
|
l := limiters[idx%replicas]
|
||||||
|
err := l.Allow(key, now)
|
||||||
|
if err == nil {
|
||||||
|
atomic.AddInt64(&allowed, 1)
|
||||||
|
} else if errors.Is(err, ratelimit.ErrRateLimited) {
|
||||||
|
atomic.AddInt64(&denied, 1)
|
||||||
|
} else {
|
||||||
|
t.Errorf("unexpected error from Allow: %v", err)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
gotAllowed := atomic.LoadInt64(&allowed)
|
||||||
|
gotDenied := atomic.LoadInt64(&denied)
|
||||||
|
|
||||||
|
t.Logf("replicas=%d cap=%d concurrent=%d → allowed=%d denied=%d",
|
||||||
|
replicas, cap, concurrentReq, gotAllowed, gotDenied)
|
||||||
|
|
||||||
|
if gotAllowed != int64(cap) {
|
||||||
|
t.Errorf("allowed = %d, want exactly %d (cross-replica row lock should serialize Allow calls so exactly cap succeed)",
|
||||||
|
gotAllowed, cap)
|
||||||
|
}
|
||||||
|
if gotDenied != int64(concurrentReq-cap) {
|
||||||
|
t.Errorf("denied = %d, want %d (concurrentReq - cap)", gotDenied, concurrentReq-cap)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Local testcontainers harness. Kept in-file because the rest of
|
||||||
|
// internal/integration/ uses HTTP-against-running-server smoke tests
|
||||||
|
// against a docker-compose stack — different shape from ours.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func startPostgresContainer(ctx context.Context, t *testing.T) (testcontainers.Container, string) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := testcontainers.ContainerRequest{
|
||||||
|
Image: "postgres:16-alpine",
|
||||||
|
ExposedPorts: []string{"5432/tcp"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"POSTGRES_DB": "certctl_test",
|
||||||
|
"POSTGRES_USER": "certctl",
|
||||||
|
"POSTGRES_PASSWORD": "certctl",
|
||||||
|
},
|
||||||
|
WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||||
|
}
|
||||||
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: req,
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start postgres container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := container.Host(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("container host: %v", err)
|
||||||
|
}
|
||||||
|
port, err := container.MappedPort(ctx, "5432")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("container port: %v", err)
|
||||||
|
}
|
||||||
|
dsn := fmt.Sprintf("postgres://certctl:certctl@%s:%s/certctl_test?sslmode=disable",
|
||||||
|
host, port.Port())
|
||||||
|
return container, dsn
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMigrationFromHere(filename string) string {
|
||||||
|
_, here, _, _ := runtime.Caller(0)
|
||||||
|
dir := filepath.Dir(here)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
candidate := filepath.Join(dir, "migrations", filename)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
dir = filepath.Dir(dir)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,412 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package ratelimit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "github.com/lib/pq"
|
||||||
|
"github.com/testcontainers/testcontainers-go"
|
||||||
|
"github.com/testcontainers/testcontainers-go/wait"
|
||||||
|
|
||||||
|
"github.com/certctl-io/certctl/internal/ratelimit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.2 closure (2026-05-14, architecture diligence audit
|
||||||
|
// ARCH-M1): backend-equivalence test suite. Runs the same scenario
|
||||||
|
// surface against both backends (in-memory + postgres) via the shared
|
||||||
|
// Limiter interface — if the postgres backend's caller-visible
|
||||||
|
// semantics drift from the memory backend's, this file fails first.
|
||||||
|
//
|
||||||
|
// Mirrors the white-box test names in sliding_window_test.go: every
|
||||||
|
// public-surface behavior pinned there (cap, expiry, disabled bypass,
|
||||||
|
// empty-key short-circuit, concurrency) gets re-pinned here for the
|
||||||
|
// postgres backend.
|
||||||
|
//
|
||||||
|
// Postgres tests skip under -short (matches the pattern in
|
||||||
|
// internal/repository/postgres/testutil_test.go); CI's
|
||||||
|
// `go test -race -short -count=1 ./...` exercises only the memory
|
||||||
|
// half. The integration job runs the full suite.
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Backend-equivalence helpers
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
// limiterFactory builds a fresh Limiter for one test case.
|
||||||
|
// Memory backends discard `db`; postgres backends use it.
|
||||||
|
type limiterFactory func(t *testing.T, db *sql.DB, maxN int, window time.Duration) ratelimit.Limiter
|
||||||
|
|
||||||
|
func memoryFactory(t *testing.T, _ *sql.DB, maxN int, window time.Duration) ratelimit.Limiter {
|
||||||
|
t.Helper()
|
||||||
|
// Map cap of 10_000 — large enough that none of the equivalence
|
||||||
|
// scenarios trip the LRU-eviction branch (the eviction branch is
|
||||||
|
// memory-specific; postgres has no equivalent so it's not part of
|
||||||
|
// the cross-backend contract).
|
||||||
|
return ratelimit.NewSlidingWindowLimiter(maxN, window, 10_000)
|
||||||
|
}
|
||||||
|
|
||||||
|
func postgresFactory(t *testing.T, db *sql.DB, maxN int, window time.Duration) ratelimit.Limiter {
|
||||||
|
t.Helper()
|
||||||
|
if db == nil {
|
||||||
|
t.Fatal("postgresFactory requires a non-nil *sql.DB")
|
||||||
|
}
|
||||||
|
return ratelimit.NewPostgresSlidingWindowLimiter(db, maxN, window)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Per-backend test entry points
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func TestSlidingWindowLimiter_Equivalence_Memory(t *testing.T) {
|
||||||
|
t.Run("AllowsUpToCap", func(t *testing.T) { caseAllowsUpToCap(t, memoryFactory, nil) })
|
||||||
|
t.Run("DistinctKeysIndependent", func(t *testing.T) { caseDistinctKeysIndependent(t, memoryFactory, nil) })
|
||||||
|
t.Run("WindowExpiry", func(t *testing.T) { caseWindowExpiry(t, memoryFactory, nil) })
|
||||||
|
t.Run("DisabledBypass", func(t *testing.T) { caseDisabledBypass(t, memoryFactory, nil) })
|
||||||
|
t.Run("NegativeCapDisabled", func(t *testing.T) { caseNegativeCapDisabled(t, memoryFactory, nil) })
|
||||||
|
t.Run("EmptyKeyShortCircuits", func(t *testing.T) { caseEmptyKeyShortCircuits(t, memoryFactory, nil) })
|
||||||
|
t.Run("ConcurrentRaceFree", func(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("race-style test under -short")
|
||||||
|
}
|
||||||
|
caseConcurrentRaceFree(t, memoryFactory, nil)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSlidingWindowLimiter_Equivalence_Postgres(t *testing.T) {
|
||||||
|
if testing.Short() {
|
||||||
|
t.Skip("postgres equivalence tests require testcontainers; skipped under -short")
|
||||||
|
}
|
||||||
|
tdb := setupTestDB(t)
|
||||||
|
defer tdb.teardown(t)
|
||||||
|
|
||||||
|
t.Run("AllowsUpToCap", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "AllowsUpToCap")
|
||||||
|
caseAllowsUpToCap(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
t.Run("DistinctKeysIndependent", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "DistinctKeysIndependent")
|
||||||
|
caseDistinctKeysIndependent(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
t.Run("WindowExpiry", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "WindowExpiry")
|
||||||
|
caseWindowExpiry(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
t.Run("DisabledBypass", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "DisabledBypass")
|
||||||
|
caseDisabledBypass(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
t.Run("NegativeCapDisabled", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "NegativeCapDisabled")
|
||||||
|
caseNegativeCapDisabled(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
t.Run("EmptyKeyShortCircuits", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "EmptyKeyShortCircuits")
|
||||||
|
caseEmptyKeyShortCircuits(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
t.Run("ConcurrentRaceFree", func(t *testing.T) {
|
||||||
|
db := tdb.freshSchema(t, "ConcurrentRaceFree")
|
||||||
|
caseConcurrentRaceFree(t, postgresFactory, db)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Backend-agnostic test cases (one per behavior pinned in
|
||||||
|
// sliding_window_test.go's public-surface tests)
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
func caseAllowsUpToCap(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
l := mk(t, db, 3, 24*time.Hour)
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
if err := l.Allow("k", now.Add(time.Duration(i)*time.Minute)); err != nil {
|
||||||
|
t.Fatalf("call %d should be allowed: %v", i+1, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := l.Allow("k", now.Add(4*time.Minute)); !errors.Is(err, ratelimit.ErrRateLimited) {
|
||||||
|
t.Fatalf("4th call should be rate-limited; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseDistinctKeysIndependent(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
l := mk(t, db, 1, 24*time.Hour)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if err := l.Allow("k-1", now); err != nil {
|
||||||
|
t.Fatalf("first allow: %v", err)
|
||||||
|
}
|
||||||
|
if err := l.Allow("k-2", now); err != nil {
|
||||||
|
t.Fatalf("different key must have its own bucket: %v", err)
|
||||||
|
}
|
||||||
|
if err := l.Allow("k-1", now.Add(1*time.Second)); !errors.Is(err, ratelimit.ErrRateLimited) {
|
||||||
|
t.Fatalf("repeat key should be limited; got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseWindowExpiry(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
l := mk(t, db, 2, 1*time.Hour)
|
||||||
|
now := time.Now()
|
||||||
|
|
||||||
|
if err := l.Allow("k", now); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := l.Allow("k", now.Add(30*time.Minute)); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
// Inside window — limited.
|
||||||
|
if err := l.Allow("k", now.Add(45*time.Minute)); !errors.Is(err, ratelimit.ErrRateLimited) {
|
||||||
|
t.Fatalf("inside-window 3rd call should be limited: %v", err)
|
||||||
|
}
|
||||||
|
// Past window — slots reopen.
|
||||||
|
if err := l.Allow("k", now.Add(2*time.Hour)); err != nil {
|
||||||
|
t.Fatalf("past-window call should be allowed (window reset): %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseDisabledBypass(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
l := mk(t, db, 0, 24*time.Hour) // maxN=0 → disabled
|
||||||
|
type disablable interface {
|
||||||
|
Disabled() bool
|
||||||
|
}
|
||||||
|
if d, ok := l.(disablable); ok && !d.Disabled() {
|
||||||
|
t.Fatal("limiter with maxN=0 must report Disabled()=true")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
if err := l.Allow("k", now); err != nil {
|
||||||
|
t.Fatalf("disabled limiter must allow everything: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseNegativeCapDisabled(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
l := mk(t, db, -1, 24*time.Hour)
|
||||||
|
type disablable interface {
|
||||||
|
Disabled() bool
|
||||||
|
}
|
||||||
|
if d, ok := l.(disablable); ok && !d.Disabled() {
|
||||||
|
t.Fatal("negative maxN must produce a disabled limiter")
|
||||||
|
}
|
||||||
|
now := time.Now()
|
||||||
|
if err := l.Allow("k", now); err != nil {
|
||||||
|
t.Fatalf("disabled limiter must allow: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseEmptyKeyShortCircuits(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
// Empty key is the caller's defense-in-depth case — caller's
|
||||||
|
// validation upstream should reject empty-key events first. Limiter
|
||||||
|
// must not build a single shared bucket keyed by empty-key — that
|
||||||
|
// would be a chokepoint for every empty-key event.
|
||||||
|
l := mk(t, db, 1, 24*time.Hour)
|
||||||
|
now := time.Now()
|
||||||
|
for i := 0; i < 50; i++ {
|
||||||
|
if err := l.Allow("", now); err != nil {
|
||||||
|
t.Fatalf("empty key must short-circuit (call %d): %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func caseConcurrentRaceFree(t *testing.T, mk limiterFactory, db *sql.DB) {
|
||||||
|
l := mk(t, db, 50, 24*time.Hour)
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
for g := 0; g < 20; g++ {
|
||||||
|
wg.Add(1)
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
now := time.Now()
|
||||||
|
key := fmt.Sprintf("k-%d", id)
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
_ = l.Allow(key, now)
|
||||||
|
}
|
||||||
|
}(g)
|
||||||
|
}
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
// Postgres-only testcontainers harness — mirrors
|
||||||
|
// internal/repository/postgres/testutil_test.go's setupTestDB +
|
||||||
|
// freshSchema pattern.
|
||||||
|
// ----------------------------------------------------------------
|
||||||
|
|
||||||
|
type testDB struct {
|
||||||
|
db *sql.DB
|
||||||
|
container testcontainers.Container
|
||||||
|
}
|
||||||
|
|
||||||
|
func setupTestDB(t *testing.T) *testDB {
|
||||||
|
t.Helper()
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
req := testcontainers.ContainerRequest{
|
||||||
|
Image: "postgres:16-alpine",
|
||||||
|
ExposedPorts: []string{"5432/tcp"},
|
||||||
|
Env: map[string]string{
|
||||||
|
"POSTGRES_DB": "certctl_test",
|
||||||
|
"POSTGRES_USER": "certctl",
|
||||||
|
"POSTGRES_PASSWORD": "certctl",
|
||||||
|
},
|
||||||
|
WaitingFor: wait.ForLog("database system is ready to accept connections").WithOccurrence(2),
|
||||||
|
}
|
||||||
|
container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
|
||||||
|
ContainerRequest: req,
|
||||||
|
Started: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("start postgres container: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
host, err := container.Host(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("container host: %v", err)
|
||||||
|
}
|
||||||
|
port, err := container.MappedPort(ctx, "5432")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("container port: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
connStr := fmt.Sprintf("postgres://certctl:certctl@%s:%s/certctl_test?sslmode=disable", host, port.Port())
|
||||||
|
db, err := sql.Open("postgres", connStr)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open db: %v", err)
|
||||||
|
}
|
||||||
|
// Pool size > 1 so the multi-goroutine concurrency case can hold
|
||||||
|
// multiple connections simultaneously; the row-lock arbitrates.
|
||||||
|
db.SetMaxOpenConns(8)
|
||||||
|
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
t.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &testDB{db: db, container: container}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (tdb *testDB) teardown(t *testing.T) {
|
||||||
|
t.Helper()
|
||||||
|
if tdb.db != nil {
|
||||||
|
tdb.db.Close()
|
||||||
|
}
|
||||||
|
if tdb.container != nil {
|
||||||
|
_ = tdb.container.Terminate(context.Background())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// freshSchema creates an isolated schema per test case + runs the
|
||||||
|
// rate_limit_buckets migration inside it. Returns a *sql.DB whose
|
||||||
|
// search_path is scoped to the new schema.
|
||||||
|
//
|
||||||
|
// Note: this helper takes a sub-test label (caller-supplied) so the
|
||||||
|
// schema name is deterministic-per-case + stable across runs. The
|
||||||
|
// canonical postgres testutil uses t.Name() but we're inside Run-
|
||||||
|
// nested subtests where t.Name() includes "/" — flatten it.
|
||||||
|
func (tdb *testDB) freshSchema(t *testing.T, label string) *sql.DB {
|
||||||
|
t.Helper()
|
||||||
|
schema := sanitizeSchemaName(label + "_" + t.Name())
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
// One connection-scoped session so SET search_path persists.
|
||||||
|
conn, err := tdb.db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("acquire conn: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := conn.ExecContext(ctx, fmt.Sprintf("CREATE SCHEMA IF NOT EXISTS %s", schema)); err != nil {
|
||||||
|
t.Fatalf("create schema: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.ExecContext(ctx, fmt.Sprintf("SET search_path TO %s, public", schema)); err != nil {
|
||||||
|
t.Fatalf("set search_path: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run the rate_limit_buckets migration in this schema. The migration
|
||||||
|
// is the only one that introduces our table; other migrations don't
|
||||||
|
// matter for limiter behavior.
|
||||||
|
migPath := findMigration("000046_rate_limit_buckets.up.sql")
|
||||||
|
body, err := os.ReadFile(migPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read migration: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := conn.ExecContext(ctx, string(body)); err != nil {
|
||||||
|
t.Fatalf("apply migration: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Cleanup(func() {
|
||||||
|
conn.ExecContext(context.Background(), fmt.Sprintf("DROP SCHEMA IF EXISTS %s CASCADE", schema))
|
||||||
|
conn.Close()
|
||||||
|
})
|
||||||
|
|
||||||
|
// Wrap the single connection in a *sql.DB-like by returning a fresh
|
||||||
|
// pool that goes through the same search_path. Simpler: just return
|
||||||
|
// the underlying *sql.DB and SET search_path session-wide by re-
|
||||||
|
// running the SET on every checkout. The cleanest move is to use
|
||||||
|
// the per-connection helper: return a *sql.DB that's actually a
|
||||||
|
// "limited to N=1 connection with search_path pinned" handle.
|
||||||
|
//
|
||||||
|
// Workaround the easy way: build a fresh *sql.DB whose dsn embeds
|
||||||
|
// search_path as a connection-time setting, so every connection
|
||||||
|
// auto-applies it.
|
||||||
|
dsn := connDSNWithSearchPath(tdb, schema)
|
||||||
|
scoped, err := sql.Open("postgres", dsn)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open scoped db: %v", err)
|
||||||
|
}
|
||||||
|
scoped.SetMaxOpenConns(8)
|
||||||
|
t.Cleanup(func() { scoped.Close() })
|
||||||
|
|
||||||
|
// Sanity: row exists / table exists.
|
||||||
|
if _, err := scoped.ExecContext(ctx, "SELECT 1 FROM rate_limit_buckets LIMIT 1"); err != nil && !strings.Contains(err.Error(), "no rows") {
|
||||||
|
// Empty table is fine; only a missing-table error matters.
|
||||||
|
// "no rows" never fires here (we used Exec not Query).
|
||||||
|
t.Fatalf("smoke select: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return scoped
|
||||||
|
}
|
||||||
|
|
||||||
|
func connDSNWithSearchPath(tdb *testDB, schema string) string {
|
||||||
|
// Derive the DSN by introspection of the container's host/port.
|
||||||
|
// Couldn't pre-store because freshSchema can be called many times.
|
||||||
|
ctx := context.Background()
|
||||||
|
host, _ := tdb.container.Host(ctx)
|
||||||
|
port, _ := tdb.container.MappedPort(ctx, "5432")
|
||||||
|
return fmt.Sprintf(
|
||||||
|
"postgres://certctl:certctl@%s:%s/certctl_test?sslmode=disable&search_path=%s,public",
|
||||||
|
host, port.Port(), schema,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func sanitizeSchemaName(name string) string {
|
||||||
|
name = strings.ToLower(name)
|
||||||
|
for _, ch := range []string{"/", " ", "-", "."} {
|
||||||
|
name = strings.ReplaceAll(name, ch, "_")
|
||||||
|
}
|
||||||
|
if len(name) > 50 {
|
||||||
|
name = name[:50]
|
||||||
|
}
|
||||||
|
return "test_rl_" + name
|
||||||
|
}
|
||||||
|
|
||||||
|
func findMigration(filename string) string {
|
||||||
|
_, here, _, _ := runtime.Caller(0)
|
||||||
|
// here = .../internal/ratelimit/equivalence_test.go
|
||||||
|
// migrations = .../migrations
|
||||||
|
dir := filepath.Dir(here)
|
||||||
|
for i := 0; i < 6; i++ {
|
||||||
|
candidate := filepath.Join(dir, "migrations", filename)
|
||||||
|
if _, err := os.Stat(candidate); err == nil {
|
||||||
|
return candidate
|
||||||
|
}
|
||||||
|
dir = filepath.Dir(dir)
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.3 (2026-05-14, architecture diligence audit
|
||||||
|
// ARCH-M1): the backend-selector factory. Wires every
|
||||||
|
// `ratelimit.NewSlidingWindowLimiter(...)` call site in
|
||||||
|
// cmd/server/main.go through here so the operator-chosen backend
|
||||||
|
// (CERTCTL_RATE_LIMIT_BACKEND={memory,postgres}) gates the limiter
|
||||||
|
// type without each call site replicating the switch.
|
||||||
|
//
|
||||||
|
// Caller-visible behavior contract: NewLimiter(backend="memory", ...)
|
||||||
|
// returns a *SlidingWindowLimiter identical to a direct
|
||||||
|
// NewSlidingWindowLimiter call. NewLimiter(backend="postgres", ...)
|
||||||
|
// returns a *PostgresSlidingWindowLimiter with the same Allow(key, now)
|
||||||
|
// signature + the same ErrRateLimited sentinel + the same maxN<=0
|
||||||
|
// disabled semantics. Sprint 13.3's "no signature change" rule is
|
||||||
|
// what makes the swap drop-in.
|
||||||
|
//
|
||||||
|
// The mapCap argument is the in-memory backend's per-instance
|
||||||
|
// key-cap (LRU-evicted under pressure). Postgres backend has no
|
||||||
|
// equivalent — the table grows until the scheduler janitor sweeps
|
||||||
|
// stale rows; mapCap is accepted + ignored for that backend so the
|
||||||
|
// factory signature stays drop-in identical to NewSlidingWindowLimiter.
|
||||||
|
|
||||||
|
// NewLimiter returns a Limiter backed by either the in-memory
|
||||||
|
// SlidingWindowLimiter (backend="memory") or the
|
||||||
|
// PostgresSlidingWindowLimiter (backend="postgres").
|
||||||
|
//
|
||||||
|
// `backend` is validated by config.Validate() at startup; any other
|
||||||
|
// value here panics — config validation is the SoT, this is just
|
||||||
|
// defensive in case the call site somehow bypasses startup
|
||||||
|
// validation.
|
||||||
|
//
|
||||||
|
// `db` is required when backend="postgres" and ignored when
|
||||||
|
// backend="memory". The factory does not nil-check db for the
|
||||||
|
// memory branch because requiring a meaningful db handle for the
|
||||||
|
// memory path would couple every limiter call site to the database
|
||||||
|
// pool unnecessarily.
|
||||||
|
//
|
||||||
|
// `maxN <= 0` disables the limiter (both backends honor the
|
||||||
|
// opt-out — all Allow calls return nil).
|
||||||
|
func NewLimiter(backend string, db *sql.DB, maxN int, window time.Duration, mapCap int) Limiter {
|
||||||
|
switch backend {
|
||||||
|
case "memory":
|
||||||
|
return NewSlidingWindowLimiter(maxN, window, mapCap)
|
||||||
|
case "postgres":
|
||||||
|
if db == nil {
|
||||||
|
panic("ratelimit.NewLimiter: backend=postgres requires a non-nil *sql.DB (config.Validate should have caught this earlier)")
|
||||||
|
}
|
||||||
|
return NewPostgresSlidingWindowLimiter(db, maxN, window)
|
||||||
|
default:
|
||||||
|
// Defensive — config.Validate() rejects anything else at
|
||||||
|
// startup. Reaching this branch implies a coding error in a
|
||||||
|
// future call site that bypasses validation.
|
||||||
|
panic(fmt.Sprintf("ratelimit.NewLimiter: unknown backend %q (must be memory or postgres)", backend))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// Limiter is the rate-limit primitive every caller in cmd/server +
|
||||||
|
// internal/api/handler + internal/service consumes. Two backends
|
||||||
|
// satisfy this interface:
|
||||||
|
//
|
||||||
|
// - SlidingWindowLimiter (in-memory; the historical default;
|
||||||
|
// declared in sliding_window.go).
|
||||||
|
// - PostgresSlidingWindowLimiter (cross-replica-consistent;
|
||||||
|
// declared in postgres_sliding_window.go; introduced in Phase 13
|
||||||
|
// Sprint 13.2 for the ARCH-M1 substantive close).
|
||||||
|
//
|
||||||
|
// Sprint 13.3 (next) wires every call site through the operator-
|
||||||
|
// chosen backend via the CERTCTL_RATELIMIT_BACKEND={memory,postgres}
|
||||||
|
// env var. Until then, both backends compile + tests for both pass,
|
||||||
|
// but the production call sites still construct SlidingWindowLimiter
|
||||||
|
// directly.
|
||||||
|
//
|
||||||
|
// Sprint 13.2 signature note: the prompt template specified
|
||||||
|
// `Allow(key string) error`, but the actual repo signature has been
|
||||||
|
// `Allow(key string, now time.Time) error` since the EST RFC 7030
|
||||||
|
// hardening master bundle Phase 4.1 — the `now` parameter is what
|
||||||
|
// makes the memory limiter testable against synthetic time. The
|
||||||
|
// interface matches the actual signature so the existing
|
||||||
|
// SlidingWindowLimiter satisfies Limiter without a method-set change.
|
||||||
|
//
|
||||||
|
// Per CLAUDE.md "the repo is truth" principle, code grounded against
|
||||||
|
// the live signature (not the prompt's draft).
|
||||||
|
type Limiter interface {
|
||||||
|
// Allow records a request at the given key/time and returns
|
||||||
|
// ErrRateLimited if the configured cap is exceeded inside the
|
||||||
|
// configured window. nil otherwise.
|
||||||
|
//
|
||||||
|
// Empty `key` short-circuits to nil (caller's defense-in-depth;
|
||||||
|
// caller upstream validation should reject empty-key events
|
||||||
|
// first — building a single shared bucket keyed by empty-key
|
||||||
|
// would be a chokepoint for every empty-key event).
|
||||||
|
//
|
||||||
|
// Disabled limiters (maxN <= 0) return nil for every call.
|
||||||
|
Allow(key string, now time.Time) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compile-time interface satisfaction checks. Drift in either
|
||||||
|
// backend's Allow signature fails the build at this file before any
|
||||||
|
// caller breaks.
|
||||||
|
var (
|
||||||
|
_ Limiter = (*SlidingWindowLimiter)(nil)
|
||||||
|
_ Limiter = (*PostgresSlidingWindowLimiter)(nil)
|
||||||
|
)
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.3 closure (2026-05-14, architecture diligence audit
|
||||||
|
// ARCH-M1): the scheduler-invoked janitor for the postgres-backed
|
||||||
|
// rate-limit bucket table. Sweeps rows whose updated_at is older than
|
||||||
|
// the longest configured window any caller uses — these rows can
|
||||||
|
// never be at-cap (every timestamp inside has aged past the window),
|
||||||
|
// so dropping them entirely is safe.
|
||||||
|
//
|
||||||
|
// The in-memory backend's prune-on-Allow path keeps buckets short-
|
||||||
|
// lived without a separate sweep; this file is postgres-only.
|
||||||
|
|
||||||
|
// PostgresGC drives the rate_limit_buckets sweep. Constructed from the
|
||||||
|
// same *sql.DB the limiters use; the scheduler holds it as a value
|
||||||
|
// satisfying the ratelimit.GarbageCollector interface (mirrors the
|
||||||
|
// shape of acme.GarbageCollector + sessions.GarbageCollector).
|
||||||
|
type PostgresGC struct {
|
||||||
|
db *sql.DB
|
||||||
|
maxWindow time.Duration
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgresGC returns a janitor that sweeps rows whose updated_at
|
||||||
|
// is older than `maxWindow` ago. Pass the longest window any caller
|
||||||
|
// in the deployment configures (the EST per-principal limiter uses
|
||||||
|
// 24h today; bump if a new caller introduces a longer window).
|
||||||
|
//
|
||||||
|
// maxWindow <= 0 disables the sweep — GarbageCollect becomes a
|
||||||
|
// no-op. Operator opt-out for sketchpad / single-replica deploys
|
||||||
|
// that still want the postgres backend (rare; the memory backend is
|
||||||
|
// the better fit).
|
||||||
|
func NewPostgresGC(db *sql.DB, maxWindow time.Duration) *PostgresGC {
|
||||||
|
return &PostgresGC{db: db, maxWindow: maxWindow}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GarbageCollect deletes every rate_limit_buckets row whose
|
||||||
|
// updated_at is older than now-maxWindow. Returns the number of
|
||||||
|
// rows deleted + any error from the DELETE.
|
||||||
|
//
|
||||||
|
// Single statement, single round-trip — operates on the
|
||||||
|
// rate_limit_buckets_updated_at_idx index introduced in migration
|
||||||
|
// 000046. Idempotent: repeated calls find 0 rows.
|
||||||
|
func (g *PostgresGC) GarbageCollect(ctx context.Context) (int64, error) {
|
||||||
|
if g.maxWindow <= 0 {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
cutoff := time.Now().Add(-g.maxWindow)
|
||||||
|
res, err := g.db.ExecContext(ctx, `
|
||||||
|
DELETE FROM rate_limit_buckets
|
||||||
|
WHERE updated_at < $1
|
||||||
|
`, cutoff)
|
||||||
|
if err != nil {
|
||||||
|
return 0, fmt.Errorf("ratelimit-gc: delete stale buckets: %w", err)
|
||||||
|
}
|
||||||
|
n, err := res.RowsAffected()
|
||||||
|
if err != nil {
|
||||||
|
// Driver doesn't expose RowsAffected; rare. Don't fail the
|
||||||
|
// sweep — the delete already ran.
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return n, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
package ratelimit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Phase 13 Sprint 13.2 closure (2026-05-14, architecture diligence audit
|
||||||
|
// ARCH-M1): the cross-replica-consistent rate-limit backend. Same
|
||||||
|
// algorithm as SlidingWindowLimiter (prune-on-Allow sliding-window log)
|
||||||
|
// but the state lives in postgres so N replicas see the same per-key
|
||||||
|
// bucket. Replaces the per-process in-memory limit when the operator
|
||||||
|
// sets CERTCTL_RATELIMIT_BACKEND=postgres (wired in Sprint 13.3).
|
||||||
|
//
|
||||||
|
// Algorithm
|
||||||
|
// =========
|
||||||
|
// Each Allow call runs a single BEGIN/COMMIT transaction:
|
||||||
|
//
|
||||||
|
// 1. INSERT ... ON CONFLICT (bucket_key) DO NOTHING — ensure the
|
||||||
|
// row exists so the SELECT FOR UPDATE below has something to lock.
|
||||||
|
// 2. SELECT timestamps FROM rate_limit_buckets WHERE bucket_key=$1
|
||||||
|
// FOR UPDATE — acquire the per-key row lock for the rest of the
|
||||||
|
// transaction.
|
||||||
|
// 3. Prune timestamps older than (now - window) in Go (reusing the
|
||||||
|
// unexported pruneOlderThan helper shared with SlidingWindowLimiter
|
||||||
|
// — single source of truth for the prune semantics).
|
||||||
|
// 4. If cardinality(pruned) >= maxN: persist the pruned state without
|
||||||
|
// appending, COMMIT, return ErrRateLimited.
|
||||||
|
// 5. Else: append `now`, persist, COMMIT, return nil.
|
||||||
|
//
|
||||||
|
// SELECT FOR UPDATE serializes Allow calls for the same key across
|
||||||
|
// replicas: replicas A and B firing simultaneous Allow("k") never
|
||||||
|
// race because Postgres' row-lock arbitrates. This is the entire
|
||||||
|
// reason for the close — the memory backend's sync.Mutex only
|
||||||
|
// arbitrates within a process; pg's row lock arbitrates the cluster.
|
||||||
|
//
|
||||||
|
// Why a transaction (not a single CTE)
|
||||||
|
// ====================================
|
||||||
|
// A "compute everything in one SQL statement" approach using
|
||||||
|
// INSERT ... ON CONFLICT DO UPDATE SET timestamps = CASE WHEN ... is
|
||||||
|
// possible but the conditional logic to gate the append on the
|
||||||
|
// pruned-cardinality requires nested CTEs whose check-then-act
|
||||||
|
// semantics are hard to read + harder to convince yourself are
|
||||||
|
// race-free across all isolation levels. The explicit transaction
|
||||||
|
// version above is correct under READ COMMITTED (Postgres' default),
|
||||||
|
// matches the memory backend's read-decide-write shape line-for-line,
|
||||||
|
// and shares the same prune helper. Two extra round-trips per Allow
|
||||||
|
// vs one is acceptable for the rate-limit hot path — the operation
|
||||||
|
// is gated anyway.
|
||||||
|
//
|
||||||
|
// Sprint 13.3 will wire the scheduler janitor loop that GCs rows
|
||||||
|
// whose updated_at is older than the longest configured window; the
|
||||||
|
// migration ships the supporting btree index on updated_at.
|
||||||
|
|
||||||
|
// PostgresSlidingWindowLimiter implements Limiter against the
|
||||||
|
// rate_limit_buckets table introduced in migration 000046.
|
||||||
|
//
|
||||||
|
// Constructed via NewPostgresSlidingWindowLimiter. The zero value is
|
||||||
|
// NOT usable — the db handle is required.
|
||||||
|
//
|
||||||
|
// Concurrency: safe for concurrent Allow calls across goroutines AND
|
||||||
|
// across N replicas (the underlying SELECT FOR UPDATE serializes
|
||||||
|
// per-key access across the cluster).
|
||||||
|
type PostgresSlidingWindowLimiter struct {
|
||||||
|
db *sql.DB
|
||||||
|
maxN int
|
||||||
|
window time.Duration
|
||||||
|
disabled bool // maxN <= 0 → all Allow calls return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewPostgresSlidingWindowLimiter returns a limiter with the given
|
||||||
|
// per-key cap + window. maxN <= 0 disables the limiter (all Allow
|
||||||
|
// calls return nil); matches the memory backend's opt-out semantics
|
||||||
|
// for test harnesses + sketchpad deploys.
|
||||||
|
//
|
||||||
|
// Window defaults to 24h when zero, mirroring SlidingWindowLimiter.
|
||||||
|
//
|
||||||
|
// The db argument is required + must outlive the limiter. Construction
|
||||||
|
// itself does NOT touch the database — DDL is owned by migration
|
||||||
|
// 000046_rate_limit_buckets.up.sql which runs at boot via
|
||||||
|
// cmd/server's RunMigrations path.
|
||||||
|
func NewPostgresSlidingWindowLimiter(db *sql.DB, maxN int, window time.Duration) *PostgresSlidingWindowLimiter {
|
||||||
|
if window <= 0 {
|
||||||
|
window = 24 * time.Hour
|
||||||
|
}
|
||||||
|
disabled := maxN <= 0
|
||||||
|
return &PostgresSlidingWindowLimiter{
|
||||||
|
db: db,
|
||||||
|
maxN: maxN,
|
||||||
|
window: window,
|
||||||
|
disabled: disabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allow records a request at the given (key, now) and returns
|
||||||
|
// ErrRateLimited if the configured cap is exceeded inside the
|
||||||
|
// configured window. Matches SlidingWindowLimiter.Allow byte-for-byte
|
||||||
|
// in caller-visible semantics so Sprint 13.3's backend-selector swap
|
||||||
|
// is signature-clean.
|
||||||
|
//
|
||||||
|
// The `now` argument is the timestamp the call is "happening at".
|
||||||
|
// Used as the prune cutoff (entries older than now-window are dropped)
|
||||||
|
// and as the new appended entry. Tests pass synthetic `now` values
|
||||||
|
// to exercise window-expiry deterministically; production call sites
|
||||||
|
// pass time.Now() (matching how SlidingWindowLimiter is invoked
|
||||||
|
// today — see internal/api/handler/{est,export,certificates,
|
||||||
|
// auth_breakglass}.go).
|
||||||
|
//
|
||||||
|
// Empty `key` short-circuits to nil (matches the memory backend's
|
||||||
|
// chokepoint-avoidance contract).
|
||||||
|
func (l *PostgresSlidingWindowLimiter) Allow(key string, now time.Time) error {
|
||||||
|
if l.disabled {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if key == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
tx, err := l.db.BeginTx(ctx, &sql.TxOptions{Isolation: sql.LevelReadCommitted})
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ratelimit: begin tx: %w", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
// Rollback is a no-op once the tx is committed; safe to defer
|
||||||
|
// unconditionally for the error paths.
|
||||||
|
_ = tx.Rollback()
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Step 1: ensure the row exists so SELECT FOR UPDATE has something
|
||||||
|
// to lock. ON CONFLICT DO NOTHING is a no-op when the row already
|
||||||
|
// exists.
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
INSERT INTO rate_limit_buckets (bucket_key, timestamps, updated_at)
|
||||||
|
VALUES ($1, '{}', $2)
|
||||||
|
ON CONFLICT (bucket_key) DO NOTHING
|
||||||
|
`, key, now); err != nil {
|
||||||
|
return fmt.Errorf("ratelimit: ensure row: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 2: lock the row + read current state. lib/pq cannot scan a
|
||||||
|
// TIMESTAMPTZ[] column back into []time.Time directly: time.Time
|
||||||
|
// does not implement sql.Scanner, and pq.GenericArray's per-element
|
||||||
|
// scan path calls Scan() (not database/sql's convertAssign), so the
|
||||||
|
// inner Scan fails with
|
||||||
|
// "pq: scanning to time.Time is not implemented; only sql.Scanner".
|
||||||
|
// Workaround: ask Postgres to format each timestamp as a canonical
|
||||||
|
// ISO 8601 UTC string via to_char(... AT TIME ZONE 'UTC', ...), read
|
||||||
|
// the column as text[] via pq.StringArray (well-supported), and
|
||||||
|
// parse Go-side. The to_char format is fully deterministic (6-digit
|
||||||
|
// microseconds, "T" separator, "Z" suffix) regardless of the
|
||||||
|
// session's DateStyle / TimeZone settings.
|
||||||
|
const pgTimestampLayout = "2006-01-02T15:04:05.000000Z"
|
||||||
|
var tsStrings pq.StringArray
|
||||||
|
if err := tx.QueryRowContext(ctx, `
|
||||||
|
SELECT COALESCE(
|
||||||
|
ARRAY(
|
||||||
|
SELECT to_char(t AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS.US"Z"')
|
||||||
|
FROM unnest(timestamps) AS t
|
||||||
|
),
|
||||||
|
ARRAY[]::text[]
|
||||||
|
)
|
||||||
|
FROM rate_limit_buckets
|
||||||
|
WHERE bucket_key = $1
|
||||||
|
FOR UPDATE
|
||||||
|
`, key).Scan(&tsStrings); err != nil {
|
||||||
|
// Shouldn't happen — step 1 ensured the row exists. Treat
|
||||||
|
// the sql.ErrNoRows path as a no-op (be conservative; never
|
||||||
|
// over-limit on transient DB weirdness).
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return fmt.Errorf("ratelimit: select-for-update: %w", err)
|
||||||
|
}
|
||||||
|
ts := make([]time.Time, 0, len(tsStrings))
|
||||||
|
for _, s := range tsStrings {
|
||||||
|
parsed, err := time.Parse(pgTimestampLayout, s)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("ratelimit: parse stored timestamp %q: %w", s, err)
|
||||||
|
}
|
||||||
|
ts = append(ts, parsed.UTC())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: prune in Go via the shared helper. Same prune semantics
|
||||||
|
// as SlidingWindowLimiter — single source of truth.
|
||||||
|
cutoff := now.Add(-l.window)
|
||||||
|
pruned := pruneOlderThan(ts, cutoff)
|
||||||
|
|
||||||
|
// Step 4: decide.
|
||||||
|
rateLimited := len(pruned) >= l.maxN
|
||||||
|
if !rateLimited {
|
||||||
|
pruned = append(pruned, now)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 5: persist.
|
||||||
|
if _, err := tx.ExecContext(ctx, `
|
||||||
|
UPDATE rate_limit_buckets
|
||||||
|
SET timestamps = $2, updated_at = $3
|
||||||
|
WHERE bucket_key = $1
|
||||||
|
`, key, pq.Array(pruned), now); err != nil {
|
||||||
|
return fmt.Errorf("ratelimit: update: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Commit(); err != nil {
|
||||||
|
return fmt.Errorf("ratelimit: commit: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if rateLimited {
|
||||||
|
return ErrRateLimited
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Disabled reports whether the limiter is in opt-out mode (maxN <= 0).
|
||||||
|
// Mirrors SlidingWindowLimiter.Disabled() so handler-side gating +
|
||||||
|
// admin-endpoint observability can ask the same question of either
|
||||||
|
// backend.
|
||||||
|
func (l *PostgresSlidingWindowLimiter) Disabled() bool {
|
||||||
|
return l.disabled
|
||||||
|
}
|
||||||
@@ -103,6 +103,21 @@ type BCLReplayGarbageCollector interface {
|
|||||||
SweepExpired(ctx context.Context, now time.Time) (int, error)
|
SweepExpired(ctx context.Context, now time.Time) (int, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// RateLimitGarbageCollector sweeps stale rows from the
|
||||||
|
// rate_limit_buckets table introduced in migration 000046. Phase 13
|
||||||
|
// Sprint 13.3 (ARCH-M1 closure completion) — wired only when
|
||||||
|
// CERTCTL_RATE_LIMIT_BACKEND=postgres. Concrete impl is
|
||||||
|
// *ratelimit.PostgresGC. Mirrors the ACMEGarbageCollector +
|
||||||
|
// SessionGarbageCollector contracts so the scheduler reuses the same
|
||||||
|
// atomic.Bool + WithTimeout + ticker pattern as the existing GC loops.
|
||||||
|
//
|
||||||
|
// Returns the row count to surface via observability logs (matches
|
||||||
|
// SessionGarbageCollector's shape — the operator wants to see
|
||||||
|
// "how many buckets did the sweep delete" in steady-state monitoring).
|
||||||
|
type RateLimitGarbageCollector interface {
|
||||||
|
GarbageCollect(ctx context.Context) (int64, error)
|
||||||
|
}
|
||||||
|
|
||||||
// JobReaperService defines the interface for job timeout reaping used by the scheduler.
|
// JobReaperService defines the interface for job timeout reaping used by the scheduler.
|
||||||
type JobReaperService interface {
|
type JobReaperService interface {
|
||||||
ReapTimedOutJobs(ctx context.Context, csrTTL, approvalTTL time.Duration) error
|
ReapTimedOutJobs(ctx context.Context, csrTTL, approvalTTL time.Duration) error
|
||||||
@@ -130,6 +145,7 @@ type Scheduler struct {
|
|||||||
acmeGC ACMEGarbageCollector
|
acmeGC ACMEGarbageCollector
|
||||||
sessionGC SessionGarbageCollector
|
sessionGC SessionGarbageCollector
|
||||||
bclReplayGC BCLReplayGarbageCollector
|
bclReplayGC BCLReplayGarbageCollector
|
||||||
|
rateLimitGC RateLimitGarbageCollector
|
||||||
jobReaper JobReaperService
|
jobReaper JobReaperService
|
||||||
logger *slog.Logger
|
logger *slog.Logger
|
||||||
|
|
||||||
@@ -149,6 +165,7 @@ type Scheduler struct {
|
|||||||
jobTimeoutInterval time.Duration
|
jobTimeoutInterval time.Duration
|
||||||
acmeGCInterval time.Duration
|
acmeGCInterval time.Duration
|
||||||
sessionGCInterval time.Duration
|
sessionGCInterval time.Duration
|
||||||
|
rateLimitGCInterval time.Duration
|
||||||
// agentOfflineJobTTL: per-tick threshold for reaping Running jobs whose
|
// agentOfflineJobTTL: per-tick threshold for reaping Running jobs whose
|
||||||
// owning agent has been silent. Bundle C / Audit M-016. Defaults below.
|
// owning agent has been silent. Bundle C / Audit M-016. Defaults below.
|
||||||
agentOfflineJobTTL time.Duration
|
agentOfflineJobTTL time.Duration
|
||||||
@@ -171,6 +188,7 @@ type Scheduler struct {
|
|||||||
jobTimeoutRunning atomic.Bool
|
jobTimeoutRunning atomic.Bool
|
||||||
acmeGCRunning atomic.Bool
|
acmeGCRunning atomic.Bool
|
||||||
sessionGCRunning atomic.Bool
|
sessionGCRunning atomic.Bool
|
||||||
|
rateLimitGCRunning atomic.Bool
|
||||||
|
|
||||||
// Graceful shutdown: wait for in-flight work to complete
|
// Graceful shutdown: wait for in-flight work to complete
|
||||||
wg sync.WaitGroup
|
wg sync.WaitGroup
|
||||||
@@ -209,6 +227,7 @@ func NewScheduler(
|
|||||||
jobTimeoutInterval: 10 * time.Minute,
|
jobTimeoutInterval: 10 * time.Minute,
|
||||||
acmeGCInterval: 1 * time.Minute,
|
acmeGCInterval: 1 * time.Minute,
|
||||||
sessionGCInterval: 1 * time.Hour,
|
sessionGCInterval: 1 * time.Hour,
|
||||||
|
rateLimitGCInterval: 5 * time.Minute,
|
||||||
// 5 minutes is 5×agentHealthCheckInterval default of 1m; an agent
|
// 5 minutes is 5×agentHealthCheckInterval default of 1m; an agent
|
||||||
// must miss multiple heartbeats before its in-flight jobs are reaped.
|
// must miss multiple heartbeats before its in-flight jobs are reaped.
|
||||||
agentOfflineJobTTL: 5 * time.Minute,
|
agentOfflineJobTTL: 5 * time.Minute,
|
||||||
@@ -365,6 +384,29 @@ func (s *Scheduler) SetSessionGCInterval(d time.Duration) {
|
|||||||
s.sessionGCInterval = d
|
s.sessionGCInterval = d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// SetRateLimitGarbageCollector wires the Phase 13 Sprint 13.3 rate-
|
||||||
|
// limit bucket GC. Optional; nil disables the loop (which is the
|
||||||
|
// correct behavior when CERTCTL_RATE_LIMIT_BACKEND=memory — the
|
||||||
|
// in-memory backend's prune-on-Allow path keeps buckets short-lived
|
||||||
|
// without a separate sweep).
|
||||||
|
//
|
||||||
|
// Concrete impl is *ratelimit.PostgresGC, constructed in
|
||||||
|
// cmd/server/main.go only when the postgres backend is selected.
|
||||||
|
func (s *Scheduler) SetRateLimitGarbageCollector(gc RateLimitGarbageCollector) {
|
||||||
|
s.rateLimitGC = gc
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRateLimitGCInterval configures the interval at which the rate-
|
||||||
|
// limit GC sweep runs. Default 5m. Wire:
|
||||||
|
// CERTCTL_RATE_LIMIT_JANITOR_INTERVAL. Zero or negative values are
|
||||||
|
// ignored.
|
||||||
|
func (s *Scheduler) SetRateLimitGCInterval(d time.Duration) {
|
||||||
|
if d <= 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.rateLimitGCInterval = d
|
||||||
|
}
|
||||||
|
|
||||||
// SetAgentOfflineJobTTL sets the threshold past which a Running job whose
|
// SetAgentOfflineJobTTL sets the threshold past which a Running job whose
|
||||||
// owning agent has gone silent is reaped to Failed. Bundle C / Audit M-016.
|
// owning agent has gone silent is reaped to Failed. Bundle C / Audit M-016.
|
||||||
// Zero or negative values are ignored (the default of 5 minutes is kept).
|
// Zero or negative values are ignored (the default of 5 minutes is kept).
|
||||||
@@ -426,6 +468,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
|
|||||||
if s.sessionGC != nil {
|
if s.sessionGC != nil {
|
||||||
loopCount++
|
loopCount++
|
||||||
}
|
}
|
||||||
|
if s.rateLimitGC != nil {
|
||||||
|
loopCount++
|
||||||
|
}
|
||||||
s.wg.Add(loopCount)
|
s.wg.Add(loopCount)
|
||||||
|
|
||||||
go func() { defer s.wg.Done(); s.renewalCheckLoop(ctx) }()
|
go func() { defer s.wg.Done(); s.renewalCheckLoop(ctx) }()
|
||||||
@@ -457,6 +502,9 @@ func (s *Scheduler) Start(ctx context.Context) <-chan struct{} {
|
|||||||
if s.sessionGC != nil {
|
if s.sessionGC != nil {
|
||||||
go func() { defer s.wg.Done(); s.sessionGCLoop(ctx) }()
|
go func() { defer s.wg.Done(); s.sessionGCLoop(ctx) }()
|
||||||
}
|
}
|
||||||
|
if s.rateLimitGC != nil {
|
||||||
|
go func() { defer s.wg.Done(); s.rateLimitGCLoop(ctx) }()
|
||||||
|
}
|
||||||
|
|
||||||
// Signal that all loops are launched
|
// Signal that all loops are launched
|
||||||
close(startedChan)
|
close(startedChan)
|
||||||
@@ -1247,3 +1295,45 @@ func (s *Scheduler) sessionGCLoop(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// rateLimitGCLoop runs every rateLimitGCInterval and invokes
|
||||||
|
// RateLimitGarbageCollector.GarbageCollect, which sweeps stale rows
|
||||||
|
// from the rate_limit_buckets table introduced in Phase 13 Sprint
|
||||||
|
// 13.2's migration 000046.
|
||||||
|
//
|
||||||
|
// Wired only when CERTCTL_RATE_LIMIT_BACKEND=postgres (the in-memory
|
||||||
|
// backend's prune-on-Allow path keeps buckets short-lived without a
|
||||||
|
// separate sweep — cmd/server/main.go skips SetRateLimitGarbageCollector
|
||||||
|
// for that case so this loop never launches).
|
||||||
|
//
|
||||||
|
// Phase 13 Sprint 13.3 closure. The atomic.Bool guard + per-tick
|
||||||
|
// context.WithTimeout match every other GC loop's pattern.
|
||||||
|
func (s *Scheduler) rateLimitGCLoop(ctx context.Context) {
|
||||||
|
ticker := NewJitteredTicker(s.rateLimitGCInterval, DefaultSchedulerJitter)
|
||||||
|
defer ticker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
if !s.rateLimitGCRunning.CompareAndSwap(false, true) {
|
||||||
|
s.logger.Warn("rate-limit GC sweep still running, skipping tick")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
s.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer s.wg.Done()
|
||||||
|
defer s.rateLimitGCRunning.Store(false)
|
||||||
|
// 1-minute timeout matches acme + session GC loops.
|
||||||
|
opCtx, cancel := context.WithTimeout(ctx, time.Minute)
|
||||||
|
defer cancel()
|
||||||
|
if n, err := s.rateLimitGC.GarbageCollect(opCtx); err != nil {
|
||||||
|
s.logger.Warn("rate-limit gc sweep failed (next tick will retry)", "error", err)
|
||||||
|
} else if n > 0 {
|
||||||
|
s.logger.Debug("rate-limit gc swept stale buckets", "rows", n)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
-- Phase 13 Sprint 13.2 reversal — drop the rate-limit bucket table.
|
||||||
|
-- Down migrations are not run in production; this file exists for
|
||||||
|
-- developer-side rollback during integration testing.
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS rate_limit_buckets_updated_at_idx;
|
||||||
|
DROP TABLE IF EXISTS rate_limit_buckets;
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
-- Phase 13 Sprint 13.2 closure (2026-05-14, architecture diligence audit
|
||||||
|
-- ARCH-M1): introduce a postgres-backed sliding-window rate limiter so
|
||||||
|
-- per-process / in-memory limits become cross-replica-consistent when
|
||||||
|
-- the operator sets CERTCTL_RATELIMIT_BACKEND=postgres (wired in
|
||||||
|
-- Sprint 13.3).
|
||||||
|
--
|
||||||
|
-- One row per (bucket_key) — caller composes the key the same way the
|
||||||
|
-- memory backend already does (e.g. "subject|issuer" for SCEP/Intune,
|
||||||
|
-- "srcIP|peek" for EST failed-basic, raw "actor" for export, etc.).
|
||||||
|
-- The `timestamps` array stores the in-window log; prune-on-Allow
|
||||||
|
-- keeps it bounded by the limiter's maxN cap.
|
||||||
|
--
|
||||||
|
-- updated_at + the index on it support the Sprint 13.3 scheduler
|
||||||
|
-- janitor loop: any row whose updated_at is older than the longest
|
||||||
|
-- configured window is safely deletable.
|
||||||
|
--
|
||||||
|
-- Per CLAUDE.md "Idempotent migrations" architecture decision:
|
||||||
|
-- IF NOT EXISTS on every statement. Re-running this migration is
|
||||||
|
-- a no-op on a database that already has the table.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS rate_limit_buckets (
|
||||||
|
bucket_key TEXT PRIMARY KEY,
|
||||||
|
timestamps TIMESTAMPTZ[] NOT NULL DEFAULT '{}',
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS rate_limit_buckets_updated_at_idx
|
||||||
|
ON rate_limit_buckets (updated_at);
|
||||||
@@ -81,6 +81,8 @@ Count: re-derive on demand via `ls scripts/ci-guards/*.sh | wc -l`. The table be
|
|||||||
| `bundle-8-M-009-bare-usemutation` | M-009 + M-029 mutation contract | Bare `useMutation()` outside `useTrackedMutation` wrapper |
|
| `bundle-8-M-009-bare-usemutation` | M-009 + M-029 mutation contract | Bare `useMutation()` outside `useTrackedMutation` wrapper |
|
||||||
| `H-1-encryption-key-min-length` | H-1 closure follow-up (post-Phase-5 surfacing) | `CERTCTL_CONFIG_ENCRYPTION_KEY` literal in any `deploy/docker-compose*.yml` shorter than the 32-byte floor enforced by `internal/config/config.go::Validate()` |
|
| `H-1-encryption-key-min-length` | H-1 closure follow-up (post-Phase-5 surfacing) | `CERTCTL_CONFIG_ENCRYPTION_KEY` literal in any `deploy/docker-compose*.yml` shorter than the 32-byte floor enforced by `internal/config/config.go::Validate()` |
|
||||||
| `test-compose-scep-coherence` | post-Phase-5 surfacing of dead SCEP test config | `CERTCTL_SCEP_ENABLED=true` in test compose without (a) a CI job that runs the SCEP integration test, (b) the `ra.crt` + `ra.key` + `intune_trust_anchor.pem` fixtures committed to `deploy/test/fixtures/`, AND (c) the matching volume mount |
|
| `test-compose-scep-coherence` | post-Phase-5 surfacing of dead SCEP test config | `CERTCTL_SCEP_ENABLED=true` in test compose without (a) a CI job that runs the SCEP integration test, (b) the `ra.crt` + `ra.key` + `intune_trust_anchor.pem` fixtures committed to `deploy/test/fixtures/`, AND (c) the matching volume mount |
|
||||||
|
| `openapi-handler-parity` | ARCH-H1 OpenAPI ↔ handler drift | Router routes vs OpenAPI operations vs documented exceptions (wire-protocol vs rest-deferred buckets). Supports `--bucket=wire-protocol\|rest-deferred` subcommand for sibling guards. |
|
||||||
|
| `openapi-rest-deferred-monotonic` | ARCH-H1 Phase 13 Sprint 13.1 — rest-deferred bucket monotonic-decrease | `category: rest-deferred` count growing vs the checked-in baseline at `api/openapi-handler-exceptions-baseline.txt`. Sprints 13.4-13.6 drive this to zero; Sprint 13.7 tightens to a zero-exact pin. |
|
||||||
|
|
||||||
### Forward-looking guards (Auditable Codebase Bundle, post-v2.1.0 anti-rot)
|
### Forward-looking guards (Auditable Codebase Bundle, post-v2.1.0 anti-rot)
|
||||||
|
|
||||||
@@ -104,3 +106,34 @@ for g in scripts/ci-guards/*.sh; do
|
|||||||
bash "$g" || echo " FAILED"
|
bash "$g" || echo " FAILED"
|
||||||
done
|
done
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## ARCH-H1 OpenAPI exception two-bucket contract (Phase 13 Sprint 13.1)
|
||||||
|
|
||||||
|
`api/openapi-handler-exceptions.yaml` lists every router route that is intentionally NOT in `api/openapi.yaml`. Each entry carries a required `category:` field with one of two values:
|
||||||
|
|
||||||
|
- **`category: wire-protocol`** — the route's wire shape is dictated by an IETF RFC (SCEP RFC 8894, ACME RFC 8555, ACME ARI RFC 9773, EST RFC 7030) or it's a sibling/shorthand variant of one. The canonical reference for these endpoints lives in `docs/acme-server.md` + `docs/operator/scep.md` + `docs/operator/est.md` — duplicating their wire contract in `openapi.yaml` would add no information. **Wire-protocol entries never burn down.**
|
||||||
|
|
||||||
|
- **`category: rest-deferred`** — the route is REST-shaped (resource CRUD, JSON request/response, RBAC-gated) but its OpenAPI operation was deferred when the handler shipped. **Rest-deferred entries must monotonically decrease to zero.** Authoring an OpenAPI op for a deferred route + deleting the corresponding exception entry + decrementing `api/openapi-handler-exceptions-baseline.txt` in the same PR is the canonical close path.
|
||||||
|
|
||||||
|
### Adding a new exception entry
|
||||||
|
|
||||||
|
The default category for new entries is `rest-deferred`. Only set `wire-protocol` when:
|
||||||
|
|
||||||
|
1. The `why:` field cites a specific RFC anchor (e.g. "RFC 8555 §7.1.1 directory"), AND
|
||||||
|
2. The route's wire shape is dictated by the RFC (not a REST resource that happens to live alongside one).
|
||||||
|
|
||||||
|
When in doubt, default to `rest-deferred` and author the OpenAPI op. The two guards in this directory enforce both buckets:
|
||||||
|
|
||||||
|
- `openapi-handler-parity.sh` reports bucket counts + fails on missing/unknown `category:` fields + fails on stale exceptions / undocumented router routes.
|
||||||
|
- `openapi-rest-deferred-monotonic.sh` fails if `rest-deferred` grows vs the baseline file at `api/openapi-handler-exceptions-baseline.txt`.
|
||||||
|
|
||||||
|
### Inspecting bucket counts
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Full report.
|
||||||
|
bash scripts/ci-guards/openapi-handler-parity.sh
|
||||||
|
|
||||||
|
# Just one bucket count (used by sibling guards).
|
||||||
|
bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
|
||||||
|
bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
|
||||||
|
```
|
||||||
|
|||||||
Executable
+47
@@ -0,0 +1,47 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase 6 closure (I18N-H2 regression gate): fail CI when a new
|
||||||
|
# `new Date(x).toLocaleString()` or `.toLocaleDateString()` ships in
|
||||||
|
# production tsx outside the canonical web/src/api/utils.ts impls.
|
||||||
|
#
|
||||||
|
# Pre-Phase-6 the codebase had 8 raw sites across 6 pages, each making
|
||||||
|
# its own locale + timezone choice. Phase 6 routed them through the
|
||||||
|
# formatDateTime / formatDate / <Timestamp> helpers in utils.ts +
|
||||||
|
# components/Timestamp.tsx. This guard prevents new raw sites from
|
||||||
|
# landing.
|
||||||
|
#
|
||||||
|
# Allowlist: web/src/api/utils.ts itself — those raw calls ARE the
|
||||||
|
# canonical implementation everyone else routes through.
|
||||||
|
#
|
||||||
|
# Tests are excluded (web/src/**/*.test.*) so test fixtures + assertions
|
||||||
|
# describing the pre-Phase-6 raw pattern don't trip the guard.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
cd "$(dirname "$0")/../../web"
|
||||||
|
|
||||||
|
OFFENDERS=$(
|
||||||
|
grep -rnE 'new Date\([^)]*\)\.toLocaleString\(\)|new Date\([^)]*\)\.toLocaleDateString\(\)' \
|
||||||
|
src \
|
||||||
|
--include='*.tsx' \
|
||||||
|
--include='*.ts' \
|
||||||
|
--exclude='*.test.*' \
|
||||||
|
--exclude-dir='node_modules' \
|
||||||
|
--exclude-dir='dist' \
|
||||||
|
2>/dev/null \
|
||||||
|
| grep -v 'src/api/utils.ts:' \
|
||||||
|
|| true
|
||||||
|
)
|
||||||
|
|
||||||
|
if [[ -n "$OFFENDERS" ]]; then
|
||||||
|
echo "::error::I18N-H2 regression: raw new Date(x).toLocaleString() outside web/src/api/utils.ts:"
|
||||||
|
echo "$OFFENDERS"
|
||||||
|
echo ""
|
||||||
|
echo "Migrate to one of:"
|
||||||
|
echo " • <Timestamp iso={...} /> — for hover-shows-other-zone UX"
|
||||||
|
echo " • formatDateTime(iso) — for local-zone date+time text"
|
||||||
|
echo " • formatDate(iso) / formatDateUTC(iso) — for date-only text"
|
||||||
|
echo ""
|
||||||
|
echo "All three live in web/src/api/utils.ts / web/src/components/Timestamp.tsx."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "I18N-H2 no-raw-toLocaleString: clean."
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
134
|
||||||
Executable
+103
@@ -0,0 +1,103 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Phase 5 closure (UX-H4 regression gate): fail the build when a new
|
||||||
|
# <label> element ships in production tsx without htmlFor= or a wrapping
|
||||||
|
# <FormField> primitive (which auto-emits htmlFor via useId()).
|
||||||
|
#
|
||||||
|
# Pre-Phase-5: 139 <label> tags, 6 with htmlFor, 0 inputs with id —
|
||||||
|
# WCAG 1.3.1 fails on ~99% of form fields. The FormField primitive
|
||||||
|
# (web/src/components/FormField.tsx) closes new label/input pairs by
|
||||||
|
# construction; this guard prevents reintroducing unbound labels in
|
||||||
|
# untouched parts of the codebase.
|
||||||
|
#
|
||||||
|
# Grace period: during the Phase 5 migration we expect ~133 existing
|
||||||
|
# unbound labels to stay in place until each owning page migrates
|
||||||
|
# through. They live in the allowlist file alongside this script
|
||||||
|
# (no-unbound-label-exceptions.txt). Each migration deletes the
|
||||||
|
# corresponding line; when the allowlist is empty, this guard becomes
|
||||||
|
# strictly enforcing and the allowlist file should be removed.
|
||||||
|
#
|
||||||
|
# Known false-positive class: wrap-style implicit-association labels —
|
||||||
|
# `<label><input/>...</label>`. These ARE a11y-safe (browsers + screen
|
||||||
|
# readers pair the wrapped input with the label automatically — no
|
||||||
|
# htmlFor needed), but this guard's line-based regex can't tell the
|
||||||
|
# wrap pattern apart from a sibling-label-no-htmlFor bug. When such
|
||||||
|
# patterns ship, raise the baseline with a one-line explanation in
|
||||||
|
# the commit message; they're benign. Phase 6 added 2 (the timestamp-
|
||||||
|
# mode radios in AuthSettingsPage), so baseline 132 → 134.
|
||||||
|
#
|
||||||
|
# Algorithm:
|
||||||
|
# 1. Count current unbound labels (labels NOT preceded by htmlFor= on
|
||||||
|
# the same line OR within the wrapping JSX block).
|
||||||
|
# 2. Compare against the allowlist's recorded count. If today's count
|
||||||
|
# is HIGHER than the allowlist baseline, a new unbound label was
|
||||||
|
# added — fail with the diff.
|
||||||
|
# 3. If today's count is LOWER, congratulate and remind to update
|
||||||
|
# the baseline.
|
||||||
|
#
|
||||||
|
# Strict mode: pass `--strict` to fail on any unbound label, ignoring
|
||||||
|
# the allowlist. Use once the allowlist is empty.
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
# Resolve script dir BEFORE cd so baseline path stays valid.
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
BASELINE_FILE="$SCRIPT_DIR/no-unbound-label-baseline.txt"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR/../../web"
|
||||||
|
|
||||||
|
STRICT=0
|
||||||
|
[[ "${1:-}" == "--strict" ]] && STRICT=1
|
||||||
|
|
||||||
|
# Count <label tags WITHOUT htmlFor= on the same line in production
|
||||||
|
# tsx (excludes tests + node_modules + dist).
|
||||||
|
COUNT_UNBOUND=$(
|
||||||
|
grep -rohE '<label[^>]*>' src \
|
||||||
|
--include='*.tsx' \
|
||||||
|
--exclude='*.test.*' \
|
||||||
|
--exclude-dir='__tests__' \
|
||||||
|
--exclude-dir='node_modules' \
|
||||||
|
--exclude-dir='dist' \
|
||||||
|
2>/dev/null \
|
||||||
|
| grep -vcE 'htmlFor='
|
||||||
|
) || true
|
||||||
|
|
||||||
|
BASELINE=0
|
||||||
|
if [[ -f "$BASELINE_FILE" ]]; then
|
||||||
|
BASELINE=$(cat "$BASELINE_FILE" | tr -d '[:space:]')
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Unbound <label> tags in web/src — current: $COUNT_UNBOUND, baseline: $BASELINE"
|
||||||
|
|
||||||
|
if [[ $STRICT -eq 1 ]]; then
|
||||||
|
if [[ $COUNT_UNBOUND -gt 0 ]]; then
|
||||||
|
echo "FAIL (--strict): $COUNT_UNBOUND unbound <label> tag(s) remain. Migrate to <FormField> or add htmlFor=."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
echo "PASS (--strict): zero unbound <label> tags."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $COUNT_UNBOUND -gt $BASELINE ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "FAIL: A new unbound <label> tag was added ($COUNT_UNBOUND > baseline $BASELINE)."
|
||||||
|
echo ""
|
||||||
|
echo "Wrap the new label in <FormField label='…'>{<input … />}</FormField> — the"
|
||||||
|
echo "primitive at web/src/components/FormField.tsx auto-pairs label htmlFor with"
|
||||||
|
echo "the child input's id via React's useId() so WCAG 1.3.1 holds by construction."
|
||||||
|
echo ""
|
||||||
|
echo "If a raw <label> is genuinely needed (rare: e.g. wrapping a Headless UI"
|
||||||
|
echo "Switch where Headless UI handles the binding internally), add htmlFor=…"
|
||||||
|
echo "explicitly. Then update the baseline:"
|
||||||
|
echo ""
|
||||||
|
echo " echo $COUNT_UNBOUND > $BASELINE_FILE"
|
||||||
|
echo ""
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ $COUNT_UNBOUND -lt $BASELINE ]]; then
|
||||||
|
echo ""
|
||||||
|
echo "PASS — and you're under baseline! Drop the baseline to lock in progress:"
|
||||||
|
echo " echo $COUNT_UNBOUND > $BASELINE_FILE"
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
|
||||||
|
exit 0
|
||||||
@@ -7,34 +7,68 @@
|
|||||||
#
|
#
|
||||||
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
|
# Per ci-pipeline-cleanup bundle Phase 9 / frozen decision 0.11.
|
||||||
#
|
#
|
||||||
# Phase 5 reconciliation (2026-05-13):
|
# Phase 13 Sprint 13.1 (2026-05-14) — every entry in the exceptions
|
||||||
# 220 r.Register call sites in internal/api/router/router.go
|
# YAML now carries a required `category: wire-protocol | rest-deferred`
|
||||||
# 209 unique (METHOD /path) router routes after de-duplication
|
# field. This script reports the two buckets alongside the total. The
|
||||||
# 158 operationIds in api/openapi.yaml
|
# rest-deferred bucket is gated by a sibling guard
|
||||||
# 64 documented exceptions in api/openapi-handler-exceptions.yaml
|
# (openapi-rest-deferred-monotonic.sh) against a checked-in baseline
|
||||||
# 0 unaccounted router routes — every route is in OpenAPI OR
|
# at api/openapi-handler-exceptions-baseline.txt.
|
||||||
# in the exceptions YAML. Guard passes clean today.
|
|
||||||
#
|
#
|
||||||
# Of the 64 exceptions:
|
# Current state (post-Sprint-13.7 / 2026-05-14):
|
||||||
# 35 wire-protocol carve-outs (SCEP RFC 8894 = 8, ACME RFC 8555
|
# 220 r.Register / r.mux.Handle call sites in internal/api/router/router.go
|
||||||
# default + per-profile = 27). These MUST stay as exceptions —
|
# 186 operationIds in api/openapi.yaml
|
||||||
# they're protocol contracts, not REST resources.
|
# 36 documented exceptions (36 wire-protocol + 0 rest-deferred)
|
||||||
# 29 REST-shaped routes deferred from openapi.yaml authoring
|
# 0 unaccounted router routes — guard passes clean today.
|
||||||
# (auth sessions, OIDC providers admin, breakglass admin,
|
#
|
||||||
# users mgmt, runtime-config, demo-residual-cleanup, audit
|
# Sprints 13.4-13.6 drove rest-deferred to zero by authoring 28 OpenAPI
|
||||||
# export). Burn-down target: author the 29 OpenAPI ops over
|
# ops + deleting the corresponding exception entries. Sprint 13.7
|
||||||
# the next ~2 sprints so the generated client (web/orval.config.ts)
|
# (this comment-block update + the inline fail-on-rest-deferred check
|
||||||
# covers them. Tracked under ARCH-H1 in
|
# at the bottom of the python block) tightens this guard's
|
||||||
# cowork/certctl-architecture-diligence-audit.html.
|
# rest-deferred floor from "monotonic-decrease vs baseline" (the
|
||||||
|
# sibling guard openapi-rest-deferred-monotonic.sh) to a HARD
|
||||||
|
# zero-exact pin. The `category: rest-deferred` escape hatch is now
|
||||||
|
# closed for good: any future PR adding a new REST route MUST author
|
||||||
|
# its OpenAPI op or fail CI.
|
||||||
|
#
|
||||||
|
# The sibling monotonic-decrease guard stays in tree as belt-and-
|
||||||
|
# suspenders — both must hold. The monotonic guard catches baseline-
|
||||||
|
# drift accidents (e.g. an operator manually edits the baseline up
|
||||||
|
# without surfacing the rationale); this guard catches the underlying
|
||||||
|
# rest-deferred bucket re-growing at all.
|
||||||
#
|
#
|
||||||
# Going forward: any new gap (in either direction) fails the build
|
# Going forward: any new gap (in either direction) fails the build
|
||||||
# unless documented in the exceptions YAML.
|
# unless documented in the exceptions YAML with category=wire-protocol
|
||||||
|
# (carry an RFC anchor in `why:` for review-time scrutiny).
|
||||||
|
#
|
||||||
|
# Subcommand:
|
||||||
|
# bash scripts/ci-guards/openapi-handler-parity.sh
|
||||||
|
# Full parity check + bucket reporting.
|
||||||
|
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=wire-protocol
|
||||||
|
# bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred
|
||||||
|
# Print just the count for the named bucket (used by sibling guards
|
||||||
|
# + Sprint 13.7's zero-exact pin). Exit 0 always; informational.
|
||||||
|
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
python3 - <<'PY'
|
BUCKET=""
|
||||||
|
case "${1:-}" in
|
||||||
|
--bucket=wire-protocol|--bucket=rest-deferred)
|
||||||
|
BUCKET="${1#--bucket=}"
|
||||||
|
;;
|
||||||
|
"")
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "::error::unknown argument: $1"
|
||||||
|
echo "usage: $0 [--bucket=wire-protocol|--bucket=rest-deferred]"
|
||||||
|
exit 2
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
|
||||||
|
python3 - "$BUCKET" <<'PY'
|
||||||
import re, sys, yaml
|
import re, sys, yaml
|
||||||
|
|
||||||
|
bucket_arg = sys.argv[1] if len(sys.argv) > 1 else ""
|
||||||
|
|
||||||
# Extract router routes: r.mux.Handle("METHOD /path", ...) and
|
# Extract router routes: r.mux.Handle("METHOD /path", ...) and
|
||||||
# r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax.
|
# r.Register("METHOD /path", ...) — Go 1.22+ ServeMux pattern syntax.
|
||||||
with open('internal/api/router/router.go') as f:
|
with open('internal/api/router/router.go') as f:
|
||||||
@@ -60,20 +94,76 @@ try:
|
|||||||
except FileNotFoundError:
|
except FileNotFoundError:
|
||||||
exc_doc = {'documented_exceptions': []}
|
exc_doc = {'documented_exceptions': []}
|
||||||
exception_set = set()
|
exception_set = set()
|
||||||
|
bucket_counts = {'wire-protocol': 0, 'rest-deferred': 0}
|
||||||
|
missing_category = []
|
||||||
|
unknown_category = []
|
||||||
for entry in (exc_doc.get('documented_exceptions') or []):
|
for entry in (exc_doc.get('documented_exceptions') or []):
|
||||||
route_str = entry['route']
|
route_str = entry['route']
|
||||||
parts = route_str.split(maxsplit=1)
|
parts = route_str.split(maxsplit=1)
|
||||||
if len(parts) == 2:
|
if len(parts) == 2:
|
||||||
exception_set.add((parts[0], parts[1]))
|
exception_set.add((parts[0], parts[1]))
|
||||||
|
cat = entry.get('category')
|
||||||
|
if cat is None:
|
||||||
|
missing_category.append(route_str)
|
||||||
|
elif cat in bucket_counts:
|
||||||
|
bucket_counts[cat] += 1
|
||||||
|
else:
|
||||||
|
unknown_category.append((route_str, cat))
|
||||||
|
|
||||||
|
# --bucket=X subcommand: print just the count, exit 0, no other output.
|
||||||
|
if bucket_arg in bucket_counts:
|
||||||
|
print(bucket_counts[bucket_arg])
|
||||||
|
sys.exit(0)
|
||||||
|
|
||||||
# Report counts
|
# Report counts
|
||||||
print(f"Router routes: {len(router_set)}")
|
print(f"Router routes: {len(router_set)}")
|
||||||
print(f"OpenAPI operations: {len(oapi_set)}")
|
print(f"OpenAPI operations: {len(oapi_set)}")
|
||||||
print(f"Documented exceptions: {len(exception_set)}")
|
print(f"Documented exceptions: {len(exception_set)}")
|
||||||
|
print(f" wire-protocol: {bucket_counts['wire-protocol']}")
|
||||||
|
print(f" rest-deferred: {bucket_counts['rest-deferred']}")
|
||||||
print()
|
print()
|
||||||
|
|
||||||
fail = False
|
fail = False
|
||||||
|
|
||||||
|
# Phase 13 Sprint 13.1: every entry MUST have a category. Missing or
|
||||||
|
# unknown categories fail the build — keeps the bucket math honest.
|
||||||
|
if missing_category:
|
||||||
|
print(f"::error::api/openapi-handler-exceptions.yaml: {len(missing_category)} entries missing required `category:` field:")
|
||||||
|
for r in missing_category:
|
||||||
|
print(f" {r}")
|
||||||
|
print()
|
||||||
|
print("Add `category: wire-protocol` (with an RFC anchor in `why:`) or")
|
||||||
|
print("author the route's OpenAPI op (the rest-deferred bucket is now")
|
||||||
|
print("pinned at zero — see Phase 13 Sprint 13.7 closure).")
|
||||||
|
fail = True
|
||||||
|
|
||||||
|
if unknown_category:
|
||||||
|
print(f"::error::api/openapi-handler-exceptions.yaml: {len(unknown_category)} entries with unknown category value (must be wire-protocol or rest-deferred):")
|
||||||
|
for r, c in unknown_category:
|
||||||
|
print(f" {r} → category: {c}")
|
||||||
|
fail = True
|
||||||
|
|
||||||
|
# Phase 13 Sprint 13.7 — hard zero-exact pin on the rest-deferred
|
||||||
|
# bucket. ARCH-H1's substantive close requires that the bucket stay
|
||||||
|
# empty in perpetuity: any new REST route MUST land with an
|
||||||
|
# OpenAPI op. Categorizing a new exception as `category: rest-deferred`
|
||||||
|
# is no longer an escape hatch — it fails CI immediately, surfacing
|
||||||
|
# the route + suggesting the fix.
|
||||||
|
if bucket_counts['rest-deferred'] > 0:
|
||||||
|
print(f"::error::rest-deferred bucket is non-empty ({bucket_counts['rest-deferred']} entries) — Phase 13 Sprint 13.7 closure pins this at zero.")
|
||||||
|
print()
|
||||||
|
print("Every entry in api/openapi-handler-exceptions.yaml with")
|
||||||
|
print("`category: rest-deferred` represents a REST-shaped route whose")
|
||||||
|
print("OpenAPI op was deferred. Author the OpenAPI op in api/openapi.yaml")
|
||||||
|
print("with a request/response schema mirroring the Go handler's")
|
||||||
|
print("projection types, then delete the exception entry.")
|
||||||
|
print()
|
||||||
|
print("Offending entries:")
|
||||||
|
for entry in (exc_doc.get('documented_exceptions') or []):
|
||||||
|
if entry.get('category') == 'rest-deferred':
|
||||||
|
print(f" {entry['route']}")
|
||||||
|
fail = True
|
||||||
|
|
||||||
# Routes in router but NOT in openapi AND NOT in exceptions = drift
|
# Routes in router but NOT in openapi AND NOT in exceptions = drift
|
||||||
router_only_undocumented = router_set - oapi_set - exception_set
|
router_only_undocumented = router_set - oapi_set - exception_set
|
||||||
if router_only_undocumented:
|
if router_only_undocumented:
|
||||||
@@ -84,8 +174,9 @@ if router_only_undocumented:
|
|||||||
print("Either:")
|
print("Either:")
|
||||||
print(" (a) Add the operationId to api/openapi.yaml (preferred for REST endpoints), OR")
|
print(" (a) Add the operationId to api/openapi.yaml (preferred for REST endpoints), OR")
|
||||||
print(" (b) Add the route to api/openapi-handler-exceptions.yaml with a one-line `why:` justification")
|
print(" (b) Add the route to api/openapi-handler-exceptions.yaml with a one-line `why:` justification")
|
||||||
print(" (only for protocol-shaped or operational routes — health probes,")
|
print(" AND a `category: wire-protocol | rest-deferred` field (only protocol-shaped")
|
||||||
print(" Prometheus scrape, SCEP/EST/OCSP wire-protocol endpoints, etc.).")
|
print(" or operational routes — health probes, Prometheus scrape, SCEP/EST/ACME")
|
||||||
|
print(" wire-protocol endpoints, etc. — qualify as wire-protocol).")
|
||||||
fail = True
|
fail = True
|
||||||
|
|
||||||
# Routes in openapi but NOT in router = orphan operationId
|
# Routes in openapi but NOT in router = orphan operationId
|
||||||
|
|||||||
+84
@@ -0,0 +1,84 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# scripts/ci-guards/openapi-rest-deferred-monotonic.sh
|
||||||
|
#
|
||||||
|
# Phase 13 Sprint 13.1 closure (2026-05-14, architecture diligence audit
|
||||||
|
# ARCH-H1): the `rest-deferred` exception bucket in
|
||||||
|
# api/openapi-handler-exceptions.yaml MUST monotonically decrease vs
|
||||||
|
# the checked-in baseline at api/openapi-handler-exceptions-baseline.txt.
|
||||||
|
#
|
||||||
|
# Contract:
|
||||||
|
# - openapi-handler-exceptions.yaml entries categorized as
|
||||||
|
# `category: rest-deferred` are REST-shaped routes whose OpenAPI
|
||||||
|
# op was deferred when the handler shipped. They are gaps, not
|
||||||
|
# contracts, and must reach zero.
|
||||||
|
# - This guard reads the current rest-deferred count via the parity
|
||||||
|
# script's --bucket subcommand, reads the baseline from
|
||||||
|
# api/openapi-handler-exceptions-baseline.txt, and fails if the
|
||||||
|
# current count exceeds the baseline.
|
||||||
|
# - Phase 13 Sprints 13.4-13.6 author the OpenAPI ops for the
|
||||||
|
# remaining 28 rest-deferred entries; each batch bumps the
|
||||||
|
# baseline file downward. Sprint 13.7 lands the baseline at 0
|
||||||
|
# AND tightens the sibling openapi-handler-parity.sh guard to a
|
||||||
|
# hard zero-exact pin.
|
||||||
|
#
|
||||||
|
# Going forward: any PR that adds a new `category: rest-deferred`
|
||||||
|
# entry without simultaneously bumping the baseline file fails CI.
|
||||||
|
#
|
||||||
|
# Operator workflow:
|
||||||
|
# 1. Land an OpenAPI op for one of the rest-deferred routes.
|
||||||
|
# 2. Delete the corresponding entry from
|
||||||
|
# api/openapi-handler-exceptions.yaml.
|
||||||
|
# 3. Decrement api/openapi-handler-exceptions-baseline.txt by the
|
||||||
|
# number of entries removed.
|
||||||
|
# 4. Commit all three changes in the same PR — this guard verifies
|
||||||
|
# they stay consistent.
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
BASELINE_FILE="api/openapi-handler-exceptions-baseline.txt"
|
||||||
|
|
||||||
|
if [ ! -f "$BASELINE_FILE" ]; then
|
||||||
|
echo "::error::missing $BASELINE_FILE — required by Phase 13 Sprint 13.1 contract."
|
||||||
|
echo ""
|
||||||
|
echo "Create it with a single integer matching the current rest-deferred count:"
|
||||||
|
echo " bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred > $BASELINE_FILE"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Whitespace-tolerant read of the baseline.
|
||||||
|
BASELINE=$(tr -d '[:space:]' < "$BASELINE_FILE")
|
||||||
|
if ! [[ "$BASELINE" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "::error::$BASELINE_FILE must contain a single non-negative integer; got: '$BASELINE'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
CURRENT=$(bash scripts/ci-guards/openapi-handler-parity.sh --bucket=rest-deferred)
|
||||||
|
if ! [[ "$CURRENT" =~ ^[0-9]+$ ]]; then
|
||||||
|
echo "::error::openapi-handler-parity.sh --bucket=rest-deferred returned non-integer: '$CURRENT'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CURRENT" -gt "$BASELINE" ]; then
|
||||||
|
echo "::error::rest-deferred bucket grew: $CURRENT > baseline $BASELINE."
|
||||||
|
echo ""
|
||||||
|
echo "Phase 13 Sprint 13.1 contract: the rest-deferred bucket in"
|
||||||
|
echo "api/openapi-handler-exceptions.yaml must monotonically decrease."
|
||||||
|
echo ""
|
||||||
|
echo "If you added a new REST route that genuinely cannot be authored into"
|
||||||
|
echo "openapi.yaml yet (e.g. work-in-progress), surface the rationale in"
|
||||||
|
echo "the PR description AND get explicit operator sign-off before"
|
||||||
|
echo "bumping $BASELINE_FILE upward. The default answer is 'author"
|
||||||
|
echo "the OpenAPI op now instead'."
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$CURRENT" -lt "$BASELINE" ]; then
|
||||||
|
echo "::error::rest-deferred bucket shrank below baseline: $CURRENT < $BASELINE."
|
||||||
|
echo ""
|
||||||
|
echo "Authoring an OpenAPI op is the right move — but the baseline file"
|
||||||
|
echo "at $BASELINE_FILE must be bumped down in the SAME commit so this"
|
||||||
|
echo "guard's pin tightens automatically. Update it to: $CURRENT"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "openapi-rest-deferred-monotonic: clean — rest-deferred = $CURRENT, baseline = $BASELINE."
|
||||||
+2
-2
@@ -1,11 +1,11 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en" class="dark">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>certctl - Certificate Control Plane</title>
|
<title>certctl - Certificate Control Plane</title>
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-slate-900 text-slate-100">
|
<body class="bg-page text-ink">
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
<script type="module" src="/src/main.tsx"></script>
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
Generated
+1476
-6
File diff suppressed because it is too large
Load Diff
+15
-2
@@ -14,22 +14,35 @@
|
|||||||
"generate": "orval --config ./orval.config.ts"
|
"generate": "orval --config ./orval.config.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@floating-ui/react": "^0.27.19",
|
||||||
|
"@fontsource-variable/inter": "^5.2.8",
|
||||||
|
"@fontsource/jetbrains-mono": "^5.2.8",
|
||||||
|
"@headlessui/react": "^2.2.10",
|
||||||
|
"@hookform/resolvers": "^5.2.2",
|
||||||
"@tanstack/react-query": "^5.90.21",
|
"@tanstack/react-query": "^5.90.21",
|
||||||
|
"cmdk": "^1.1.1",
|
||||||
|
"lucide-react": "^1.16.0",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
|
"react-hook-form": "^7.75.0",
|
||||||
"react-router-dom": "^6.30.3",
|
"react-router-dom": "^6.30.3",
|
||||||
"recharts": "^3.8.0"
|
"recharts": "^3.8.0",
|
||||||
|
"sonner": "^2.0.7",
|
||||||
|
"zod": "^4.4.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@axe-core/react": "^4.11.3",
|
||||||
"@playwright/test": "^1.49.0",
|
"@playwright/test": "^1.49.0",
|
||||||
"@testing-library/jest-dom": "^6.9.1",
|
"@testing-library/jest-dom": "^6.9.1",
|
||||||
"@testing-library/react": "^16.3.2",
|
"@testing-library/react": "^16.3.2",
|
||||||
"orval": "^7.0.0",
|
"@types/jest-axe": "^3.5.9",
|
||||||
"@types/react": "^19.2.14",
|
"@types/react": "^19.2.14",
|
||||||
"@types/react-dom": "^19.2.3",
|
"@types/react-dom": "^19.2.3",
|
||||||
"@vitejs/plugin-react": "^6.0.1",
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
"autoprefixer": "^10.4.27",
|
"autoprefixer": "^10.4.27",
|
||||||
|
"jest-axe": "^10.0.0",
|
||||||
"jsdom": "^29.0.0",
|
"jsdom": "^29.0.0",
|
||||||
|
"orval": "^7.0.0",
|
||||||
"postcss": "^8.5.8",
|
"postcss": "^8.5.8",
|
||||||
"tailwindcss": "^3.4.19",
|
"tailwindcss": "^3.4.19",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^5.9.3",
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { formatNumber, formatCompact, formatPercent, formatBytes } from './format';
|
||||||
|
|
||||||
|
describe('format', () => {
|
||||||
|
describe('formatNumber', () => {
|
||||||
|
it('formats integers with thousand separator', () => {
|
||||||
|
// Locale-tolerant: any of "5,432" (en) / "5.432" (de) / "5 432" (fr) is fine.
|
||||||
|
const out = formatNumber(5432);
|
||||||
|
expect(out).toMatch(/^5[ .,]?432$/);
|
||||||
|
});
|
||||||
|
it('limits fraction digits to 2', () => {
|
||||||
|
const out = formatNumber(1.23456);
|
||||||
|
expect(out).toMatch(/^1[.,]23$/);
|
||||||
|
});
|
||||||
|
it('returns dash for NaN / Infinity', () => {
|
||||||
|
expect(formatNumber(NaN)).toBe('—');
|
||||||
|
expect(formatNumber(Infinity)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatCompact', () => {
|
||||||
|
it('compacts thousands to K', () => {
|
||||||
|
// English: "5.4K"; some locales drop the K. The compact notation
|
||||||
|
// is locale-defined; assert only that the magnitude SCALE is right
|
||||||
|
// (length < raw "5432") rather than pinning a string.
|
||||||
|
const out = formatCompact(5432);
|
||||||
|
expect(out.length).toBeLessThan('5432'.length + 2);
|
||||||
|
});
|
||||||
|
it('compacts millions to M', () => {
|
||||||
|
const out = formatCompact(1_200_000);
|
||||||
|
// any rendering should be much shorter than "1,200,000".
|
||||||
|
expect(out.length).toBeLessThan(10);
|
||||||
|
});
|
||||||
|
it('returns dash for NaN', () => {
|
||||||
|
expect(formatCompact(NaN)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatPercent', () => {
|
||||||
|
it('renders 0.995 as 99.5%', () => {
|
||||||
|
const out = formatPercent(0.995);
|
||||||
|
// en: "99.5%"; fr: "99,5 %"; both contain "99" + ("5" or no fraction)
|
||||||
|
expect(out).toMatch(/99[.,]?5?\s?%/);
|
||||||
|
});
|
||||||
|
it('renders 0 as 0%', () => {
|
||||||
|
expect(formatPercent(0)).toMatch(/^0\s?%$/);
|
||||||
|
});
|
||||||
|
it('returns dash for NaN', () => {
|
||||||
|
expect(formatPercent(NaN)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('formatBytes', () => {
|
||||||
|
it('formats < 1KB as bytes', () => {
|
||||||
|
expect(formatBytes(512)).toMatch(/^512 B$/);
|
||||||
|
});
|
||||||
|
it('formats KB scale', () => {
|
||||||
|
const out = formatBytes(5_400);
|
||||||
|
expect(out).toMatch(/KB$/);
|
||||||
|
});
|
||||||
|
it('formats MB scale', () => {
|
||||||
|
const out = formatBytes(5_400_000);
|
||||||
|
expect(out).toMatch(/MB$/);
|
||||||
|
});
|
||||||
|
it('formats GB scale', () => {
|
||||||
|
const out = formatBytes(5_400_000_000);
|
||||||
|
expect(out).toMatch(/GB$/);
|
||||||
|
});
|
||||||
|
it('returns dash for NaN', () => {
|
||||||
|
expect(formatBytes(NaN)).toBe('—');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Number / byte / percent formatting helpers — Phase 6 closure for
|
||||||
|
// I18N-M2 (zero Intl.NumberFormat usage; cert counts via
|
||||||
|
// .toLocaleString() on numbers — browser-locale-aware — sit alongside
|
||||||
|
// .toFixed(1) not localized at all).
|
||||||
|
//
|
||||||
|
// All helpers route through `Intl.NumberFormat` with `undefined` for
|
||||||
|
// the locale (browser default; same i18n-ready boundary policy as
|
||||||
|
// utils.ts). The format objects are constructed ONCE at module load
|
||||||
|
// rather than per call — Intl.NumberFormat construction is the
|
||||||
|
// expensive part; .format() is cheap.
|
||||||
|
//
|
||||||
|
// When the i18n framework lands (Phase 10) the only change here is
|
||||||
|
// to thread a `locale` arg through; the display code that imports
|
||||||
|
// these helpers stays unchanged.
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Standard integer / decimal formatter — "5,432.10" in en, "5.432,10"
|
||||||
|
* in de-DE, "5 432,10" in fr-FR. Use for cert counts, agent counts,
|
||||||
|
* issuance rates, anything that's a count or a non-byte/non-percent
|
||||||
|
* scalar.
|
||||||
|
*/
|
||||||
|
const numberFmt = new Intl.NumberFormat(undefined, {
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact / abbreviated formatter — "5.4K", "1.2M". Use for stat tiles
|
||||||
|
* where vertical space is constrained and ballpark magnitude beats
|
||||||
|
* exact value. Intl.NumberFormat's `notation: 'compact'` follows
|
||||||
|
* locale conventions (English K/M/B vs CJK 万/億 etc.) automatically.
|
||||||
|
*/
|
||||||
|
const compactFmt = new Intl.NumberFormat(undefined, {
|
||||||
|
notation: 'compact',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Percent formatter — input is a fraction in [0, 1] OR an explicit
|
||||||
|
* percentage with `style: 'percent'` semantics. We default to "input
|
||||||
|
* is a fraction" because that's the common case for success-rate /
|
||||||
|
* error-rate / etc. Output: "99.5%" (en) / "99,5 %" (fr).
|
||||||
|
*/
|
||||||
|
const percentFmt = new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'percent',
|
||||||
|
minimumFractionDigits: 0,
|
||||||
|
maximumFractionDigits: 2,
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bytes formatter — Intl.NumberFormat with `style: 'unit'` and the
|
||||||
|
* byte unit. Output: "5.4 MB" (en) / "5,4 MB" (fr). Browser does the
|
||||||
|
* SI scaling automatically when given a base unit + value. For
|
||||||
|
* non-SI binary (KiB / MiB / GiB), use the manual scaler below.
|
||||||
|
*
|
||||||
|
* Note: Safari < 14 doesn't support the 'unit' style. The fallback
|
||||||
|
* branches produce "5.4 MB" without locale awareness; an operator on
|
||||||
|
* old Safari sees consistent-but-American output, which is the same
|
||||||
|
* graceful-degradation contract as the rest of the i18n boundary.
|
||||||
|
*/
|
||||||
|
const bytesFmt = (() => {
|
||||||
|
try {
|
||||||
|
return new Intl.NumberFormat(undefined, {
|
||||||
|
style: 'unit',
|
||||||
|
unit: 'megabyte',
|
||||||
|
maximumFractionDigits: 1,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return null; // signals fallback
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
||||||
|
/** Format an integer or decimal in the operator's locale. */
|
||||||
|
export function formatNumber(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return '—';
|
||||||
|
return numberFmt.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compact-format a magnitude — 1500 → "1.5K", 1_500_000 → "1.5M".
|
||||||
|
* Use for tile labels + chart axis ticks.
|
||||||
|
*/
|
||||||
|
export function formatCompact(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return '—';
|
||||||
|
return compactFmt.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a fraction in [0, 1] as a percentage. Pass 0.995 → "99.5%".
|
||||||
|
* For an already-percentified value (e.g. server returns 99.5 not
|
||||||
|
* 0.995), divide by 100 at the call site.
|
||||||
|
*/
|
||||||
|
export function formatPercent(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return '—';
|
||||||
|
return percentFmt.format(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a byte count with SI-decimal scaling (1KB = 1000B). Output
|
||||||
|
* locale-aware where possible; falls back to "5.4 MB"-style English
|
||||||
|
* on old Safari (see bytesFmt comment above).
|
||||||
|
*
|
||||||
|
* For binary scaling (1KiB = 1024B) use formatBytesBinary — relevant
|
||||||
|
* for memory / disk numbers that surface in Observability tiles.
|
||||||
|
*/
|
||||||
|
export function formatBytes(value: number): string {
|
||||||
|
if (!Number.isFinite(value)) return '—';
|
||||||
|
const { magnitude, unit } = pickSIUnit(value);
|
||||||
|
const scaled = value / magnitude;
|
||||||
|
if (bytesFmt) {
|
||||||
|
// Intl.NumberFormat doesn't accept the unit dynamically post-
|
||||||
|
// construction — we'd need a per-unit cache for that. Simpler:
|
||||||
|
// format the scaled magnitude with the standard number formatter
|
||||||
|
// and append the unit. Locale-aware decimal separator + space.
|
||||||
|
return `${numberFmt.format(round1(scaled))} ${unit}`;
|
||||||
|
}
|
||||||
|
return `${round1(scaled)} ${unit}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickSIUnit(bytes: number): { magnitude: number; unit: string } {
|
||||||
|
const abs = Math.abs(bytes);
|
||||||
|
if (abs >= 1e12) return { magnitude: 1e12, unit: 'TB' };
|
||||||
|
if (abs >= 1e9) return { magnitude: 1e9, unit: 'GB' };
|
||||||
|
if (abs >= 1e6) return { magnitude: 1e6, unit: 'MB' };
|
||||||
|
if (abs >= 1e3) return { magnitude: 1e3, unit: 'KB' };
|
||||||
|
return { magnitude: 1, unit: 'B' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function round1(v: number): number {
|
||||||
|
return Math.round(v * 10) / 10;
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// queryConstants — the TanStack Query staleTime / gcTime tier model.
|
||||||
|
// Phase 2 closure for TQ-M2 (twelve inconsistent staleTime override
|
||||||
|
// values 15s–5min with no governing principle) + TQ-M1 (zero gcTime
|
||||||
|
// overrides; 5-min default holds stale data across 87 pages of nav).
|
||||||
|
//
|
||||||
|
// Tier model
|
||||||
|
// ==========
|
||||||
|
// staleTime answers: "how long can the cached value be served as-is
|
||||||
|
// without firing a background refetch?". Three tiers:
|
||||||
|
//
|
||||||
|
// REAL_TIME 15s — data that needs to look live for an operator
|
||||||
|
// watching a workflow finish: in-flight jobs,
|
||||||
|
// running agent heartbeats, scan progress,
|
||||||
|
// certs-by-status. Refetch on window focus.
|
||||||
|
// REFERENCE 5min — list endpoints + reference data: issuers,
|
||||||
|
// profiles, owners, teams, agent groups,
|
||||||
|
// certificate listings, audit log. The dominant
|
||||||
|
// case in the codebase. No window-focus refetch.
|
||||||
|
// CONSTANT 1hr — server-side metadata that's effectively
|
||||||
|
// immutable in a normal session: OpenAPI spec,
|
||||||
|
// version metadata, permission catalogue,
|
||||||
|
// RBAC role list.
|
||||||
|
//
|
||||||
|
// gcTime answers: "how long should the cached value linger after
|
||||||
|
// every observer unmounts before garbage-collection?". Three tiers:
|
||||||
|
//
|
||||||
|
// HEAVY 1min — large payloads that pile up memory if held
|
||||||
|
// long after the consumer page closed
|
||||||
|
// (certificate listings, audit-log pages,
|
||||||
|
// chart-data series).
|
||||||
|
// STANDARD 5min — the default for normal pages — held long
|
||||||
|
// enough that revisits within a typical
|
||||||
|
// workflow get an instant cache hit, but not
|
||||||
|
// so long that the user's tab balloons.
|
||||||
|
// REFERENCE 30min — small, reusable data fetched on most pages
|
||||||
|
// (RBAC catalogue, issuer/profile dropdown
|
||||||
|
// options). Holding 30 min means the operator
|
||||||
|
// navigating between Certificates / Targets /
|
||||||
|
// Profiles / Issuers gets the same dropdown
|
||||||
|
// cache without re-fetching.
|
||||||
|
//
|
||||||
|
// Migration policy: every new useQuery should pick ONE staleTime tier
|
||||||
|
// + ONE gcTime tier. Bare numeric values are forbidden; the rg-based
|
||||||
|
// CI guard will flag any new `staleTime:` not followed by
|
||||||
|
// `STALE_TIME.` and `gcTime:` not followed by `GC_TIME.`.
|
||||||
|
|
||||||
|
// staleTime — how long the cached value is "fresh" (no background refetch).
|
||||||
|
export const STALE_TIME = {
|
||||||
|
/** 15s — live tile data (in-flight jobs, agent heartbeats, scan progress). */
|
||||||
|
REAL_TIME: 15_000,
|
||||||
|
/** 5min — list endpoints + reference data. The dominant case. */
|
||||||
|
REFERENCE: 5 * 60_000,
|
||||||
|
/** 1hr — effectively immutable in a normal session (catalogues, metadata). */
|
||||||
|
CONSTANT: 60 * 60_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// gcTime — how long the cached value lingers after every observer unmounts.
|
||||||
|
export const GC_TIME = {
|
||||||
|
/** 1min — large payloads (cert listings, audit pages, chart series). */
|
||||||
|
HEAVY: 60_000,
|
||||||
|
/** 5min — the normal-page default. */
|
||||||
|
STANDARD: 5 * 60_000,
|
||||||
|
/** 30min — small reusable dropdown / catalogue data. */
|
||||||
|
REFERENCE: 30 * 60_000,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
// Convenience exports for the explicit tier names — useful when the
|
||||||
|
// caller wants to log the tier alongside the actual ms value (TanStack
|
||||||
|
// Devtools prints the millisecond integer; this lets you cross-ref
|
||||||
|
// the symbolic name).
|
||||||
|
export type StaleTimeTier = keyof typeof STALE_TIME;
|
||||||
|
export type GcTimeTier = keyof typeof GC_TIME;
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Operator timestamp-display preference — Phase 6 closure for I18N-H3.
|
||||||
|
//
|
||||||
|
// Default: 'utc' (frontend display ≡ server audit log byte-for-byte).
|
||||||
|
// Operators who prefer their local time explicitly opt in; operators
|
||||||
|
// running across timezones (e.g. an EU admin watching a US-East server)
|
||||||
|
// can pick a Custom IANA timezone.
|
||||||
|
//
|
||||||
|
// Storage: localStorage. No backend round-trip — the preference is
|
||||||
|
// purely cosmetic + per-browser. If the operator clears storage they
|
||||||
|
// reset to the safe default.
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'certctl:timestamp-display';
|
||||||
|
|
||||||
|
export type TimestampMode = 'utc' | 'local' | 'custom';
|
||||||
|
|
||||||
|
export interface TimestampPref {
|
||||||
|
mode: TimestampMode;
|
||||||
|
/** Only meaningful when mode === 'custom'. IANA TZ name, e.g. 'America/New_York'. */
|
||||||
|
customTz: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT: TimestampPref = { mode: 'utc', customTz: 'UTC' };
|
||||||
|
|
||||||
|
/** Read the current preference. Always returns a valid value (defaults on parse/missing). */
|
||||||
|
export function getTimestampPref(): TimestampPref {
|
||||||
|
if (typeof localStorage === 'undefined') return DEFAULT;
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
if (!raw) return DEFAULT;
|
||||||
|
const parsed = JSON.parse(raw) as Partial<TimestampPref>;
|
||||||
|
if (parsed.mode !== 'utc' && parsed.mode !== 'local' && parsed.mode !== 'custom') {
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
mode: parsed.mode,
|
||||||
|
customTz: typeof parsed.customTz === 'string' && parsed.customTz.length > 0
|
||||||
|
? parsed.customTz
|
||||||
|
: DEFAULT.customTz,
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
return DEFAULT;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Write the preference. Silently no-ops if storage unavailable (e.g. private mode). */
|
||||||
|
export function setTimestampPref(pref: TimestampPref): void {
|
||||||
|
if (typeof localStorage === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(pref));
|
||||||
|
// Fire a custom event so live <Timestamp> components can re-render
|
||||||
|
// without a page reload. Vanilla CustomEvent — works in every
|
||||||
|
// browser certctl supports.
|
||||||
|
window.dispatchEvent(new CustomEvent('certctl:timestamp-pref-changed', { detail: pref }));
|
||||||
|
} catch { /* noop */ }
|
||||||
|
}
|
||||||
+86
-2
@@ -1,11 +1,95 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Date / time / display helpers — the i18n-ready boundary the rest of
|
||||||
|
// the frontend consumes. Phase 6 closure for I18N-H1 + I18N-H2 + I18N-H3.
|
||||||
|
//
|
||||||
|
// Locale handling:
|
||||||
|
// • Pre-Phase-6 these helpers hardcoded `'en-US'`, so a German /
|
||||||
|
// French / Japanese operator saw English month names regardless
|
||||||
|
// of their browser locale.
|
||||||
|
// • Post-Phase-6 we pass `undefined` for the locale arg, which makes
|
||||||
|
// the runtime use the browser default (navigator.language). The
|
||||||
|
// options object stays — `month: 'short'` etc. — so the SHAPE of
|
||||||
|
// the output is stable across locales while the language follows
|
||||||
|
// the user.
|
||||||
|
// • When a hard i18n framework lands (Phase 10), this file is the
|
||||||
|
// single migration target. Display code never reaches for
|
||||||
|
// Date.prototype.toLocaleString directly any more — Phase 6's CI
|
||||||
|
// guard at scripts/ci-guards/no-raw-toLocaleString.sh prevents
|
||||||
|
// regression.
|
||||||
|
//
|
||||||
|
// Timezone handling (I18N-H3):
|
||||||
|
// • formatDate / formatDateTime use the runtime's local timezone —
|
||||||
|
// keeps the existing operator-friendly default.
|
||||||
|
// • formatDateUTC / formatDateTimeUTC are explicit-UTC siblings.
|
||||||
|
// The audit-log table on the server emits UTC, so these helpers
|
||||||
|
// give the frontend a way to render the same byte-for-byte
|
||||||
|
// timestamp the operator sees in `journalctl -u certctl` or in a
|
||||||
|
// `psql` query.
|
||||||
|
// • <Timestamp iso={...} /> (web/src/components/Timestamp.tsx) wraps
|
||||||
|
// a UTC render in a Phase 1 Tooltip showing the operator-local
|
||||||
|
// equivalent. Default display is UTC (so screen ≡ logs); operators
|
||||||
|
// opt into local via the AuthSettingsPage "Timestamp display"
|
||||||
|
// preference.
|
||||||
|
|
||||||
|
const DATE_OPTS: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
};
|
||||||
|
|
||||||
|
const DATETIME_OPTS: Intl.DateTimeFormatOptions = {
|
||||||
|
year: 'numeric',
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Format an ISO timestamp as a date in the browser's local timezone. */
|
||||||
export function formatDate(iso: string | undefined | null): string {
|
export function formatDate(iso: string | undefined | null): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return new Date(iso).toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' });
|
// `undefined` for the locale arg = use the browser default
|
||||||
|
// (navigator.language). DO NOT hardcode 'en-US' here — that was
|
||||||
|
// the I18N-H1 bug Phase 6 closes.
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, DATE_OPTS);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Format an ISO timestamp as a date+time in the browser's local timezone. */
|
||||||
export function formatDateTime(iso: string | undefined | null): string {
|
export function formatDateTime(iso: string | undefined | null): string {
|
||||||
if (!iso) return '—';
|
if (!iso) return '—';
|
||||||
return new Date(iso).toLocaleString('en-US', { year: 'numeric', month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
return new Date(iso).toLocaleString(undefined, DATETIME_OPTS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format an ISO timestamp as a date forced to UTC. */
|
||||||
|
export function formatDateUTC(iso: string | undefined | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleDateString(undefined, { ...DATE_OPTS, timeZone: 'UTC' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO timestamp as a date+time forced to UTC.
|
||||||
|
* Matches the format certctl-server emits to journalctl + audit_events.
|
||||||
|
* Operator can cross-reference frontend display ≡ server log byte-for-byte.
|
||||||
|
*/
|
||||||
|
export function formatDateTimeUTC(iso: string | undefined | null): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone: 'UTC' });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format an ISO timestamp in an operator-specified timezone (IANA TZ name).
|
||||||
|
* Used by <Timestamp /> when the operator picks "Custom TZ" in settings.
|
||||||
|
* Falls back to UTC if the timezone name is invalid (Intl throws RangeError).
|
||||||
|
*/
|
||||||
|
export function formatDateTimeInZone(iso: string | undefined | null, timeZone: string): string {
|
||||||
|
if (!iso) return '—';
|
||||||
|
try {
|
||||||
|
return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone });
|
||||||
|
} catch {
|
||||||
|
return new Date(iso).toLocaleString(undefined, { ...DATETIME_OPTS, timeZone: 'UTC' });
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// D-2 (master): widened to accept undefined/null since several Go-side
|
// D-2 (master): widened to accept undefined/null since several Go-side
|
||||||
|
|||||||
@@ -0,0 +1,66 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import Banner from './Banner';
|
||||||
|
|
||||||
|
describe('Banner', () => {
|
||||||
|
it('renders the children', () => {
|
||||||
|
render(<Banner type="info">Operator note</Banner>);
|
||||||
|
expect(screen.getByText('Operator note')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the optional title', () => {
|
||||||
|
render(
|
||||||
|
<Banner type="error" title="Save failed">
|
||||||
|
Permission denied.
|
||||||
|
</Banner>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Save failed')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Permission denied.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses role="alert" for error variant', () => {
|
||||||
|
render(<Banner type="error">Permission denied.</Banner>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses role="alert" for warning variant', () => {
|
||||||
|
render(<Banner type="warning">Stale data.</Banner>);
|
||||||
|
expect(screen.getByRole('alert')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses role="status" for success variant', () => {
|
||||||
|
render(<Banner type="success">Saved.</Banner>);
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses role="status" for info variant', () => {
|
||||||
|
render(<Banner type="info">Heads up.</Banner>);
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies variant-specific bg + border classes', () => {
|
||||||
|
const { container } = render(<Banner type="error">err</Banner>);
|
||||||
|
const root = container.firstChild as HTMLElement;
|
||||||
|
expect(root.className).toContain('bg-red-50');
|
||||||
|
expect(root.className).toContain('border-red-200');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hides dismiss button when onDismiss not supplied', () => {
|
||||||
|
render(<Banner type="info">No close affordance.</Banner>);
|
||||||
|
expect(screen.queryByRole('button', { name: /dismiss/i })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders dismiss button + fires onDismiss when supplied', () => {
|
||||||
|
const onDismiss = vi.fn();
|
||||||
|
render(
|
||||||
|
<Banner type="info" onDismiss={onDismiss}>
|
||||||
|
Closable.
|
||||||
|
</Banner>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /dismiss/i }));
|
||||||
|
expect(onDismiss).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Banner — the certctl-themed alert / message banner primitive. Phase 1
|
||||||
|
// closure for FE-M4 (no banner primitives; ~102 inline
|
||||||
|
// bg-(red|amber|yellow)-50 copy-paste sites across the codebase).
|
||||||
|
//
|
||||||
|
// Four severity variants:
|
||||||
|
// - error red surface, role="alert" — operator action required
|
||||||
|
// - warning amber surface, role="alert" — risky-but-not-fatal
|
||||||
|
// - success teal surface, role="status" — confirmation of last action
|
||||||
|
// - info blue surface, role="status" — neutral context
|
||||||
|
//
|
||||||
|
// role="alert" on error + warning surfaces these to screen readers
|
||||||
|
// immediately on render (aria-live=assertive equivalent). role="status"
|
||||||
|
// on success + info surfaces them politely (aria-live=polite).
|
||||||
|
//
|
||||||
|
// Optional `onDismiss` adds a close button — useful for transient
|
||||||
|
// banners. Persistent banners (e.g. "TLS bootstrap incomplete") omit
|
||||||
|
// it so the operator can't paper over the underlying state.
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export type BannerType = 'error' | 'warning' | 'success' | 'info';
|
||||||
|
|
||||||
|
export interface BannerProps {
|
||||||
|
type: BannerType;
|
||||||
|
title?: string;
|
||||||
|
children: ReactNode;
|
||||||
|
onDismiss?: () => void;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const variantStyles: Record<BannerType, string> = {
|
||||||
|
error: 'bg-red-50 border-red-200 text-red-800',
|
||||||
|
warning: 'bg-amber-50 border-amber-200 text-amber-800',
|
||||||
|
success: 'bg-emerald-50 border-emerald-200 text-emerald-800',
|
||||||
|
info: 'bg-blue-50 border-blue-200 text-blue-800',
|
||||||
|
};
|
||||||
|
|
||||||
|
const variantTitleStyles: Record<BannerType, string> = {
|
||||||
|
error: 'text-red-900',
|
||||||
|
warning: 'text-amber-900',
|
||||||
|
success: 'text-emerald-900',
|
||||||
|
info: 'text-blue-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Banner({
|
||||||
|
type,
|
||||||
|
title,
|
||||||
|
children,
|
||||||
|
onDismiss,
|
||||||
|
className = '',
|
||||||
|
}: BannerProps) {
|
||||||
|
// role="alert" announces immediately; role="status" announces politely.
|
||||||
|
// Use alert for actionable / dangerous; status for confirmation /
|
||||||
|
// background context.
|
||||||
|
const role = type === 'error' || type === 'warning' ? 'alert' : 'status';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role={role}
|
||||||
|
className={`border-l-4 p-3 rounded ${variantStyles[type]} ${className}`}
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<div className="flex-1 text-sm">
|
||||||
|
{title && (
|
||||||
|
<div className={`font-semibold mb-0.5 ${variantTitleStyles[type]}`}>
|
||||||
|
{title}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div>{children}</div>
|
||||||
|
</div>
|
||||||
|
{onDismiss && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onDismiss}
|
||||||
|
aria-label="Dismiss"
|
||||||
|
className={`text-xl leading-none opacity-60 hover:opacity-100 transition-opacity ${variantTitleStyles[type]}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Breadcrumbs tests — Phase 3 UX-M5 closure.
|
||||||
|
// Verifies the useLocation()-driven segment-walker:
|
||||||
|
// (a) root path "/" → no crumbs rendered (no empty <nav>)
|
||||||
|
// (b) top-level paths → Home + that page
|
||||||
|
// (c) detail paths → Home + List + Detail
|
||||||
|
// (d) deeply-nested /issuers/:id/hierarchy → Home + Issuers + Detail + Hierarchy
|
||||||
|
// (e) /auth/ subtree → uses authSubsegmentLabels
|
||||||
|
// (f) terminal crumb has aria-current="page" and is plain text;
|
||||||
|
// intermediate crumbs are <Link>s
|
||||||
|
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import { MemoryRouter } from 'react-router-dom';
|
||||||
|
import Breadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
|
function renderAt(pathname: string) {
|
||||||
|
return render(
|
||||||
|
<MemoryRouter initialEntries={[pathname]}>
|
||||||
|
<Breadcrumbs />
|
||||||
|
</MemoryRouter>,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('Breadcrumbs', () => {
|
||||||
|
it('renders nothing for the dashboard root', () => {
|
||||||
|
const { container } = renderAt('/');
|
||||||
|
expect(container.querySelector('nav')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Home + Certificates for /certificates', () => {
|
||||||
|
renderAt('/certificates');
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Certificates')).toBeInTheDocument();
|
||||||
|
const items = document.querySelectorAll('nav[aria-label="Breadcrumb"] ol > li');
|
||||||
|
expect(items.length).toBe(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders Home + Certificates + Detail for /certificates/cert-001', () => {
|
||||||
|
renderAt('/certificates/cert-001');
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Certificates')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Detail')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('walks /issuers/:id/hierarchy down to the Hierarchy leaf', () => {
|
||||||
|
renderAt('/issuers/iss-vault/hierarchy');
|
||||||
|
expect(screen.getByText('Home')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Issuers')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Detail')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Hierarchy')).toBeInTheDocument();
|
||||||
|
// Hierarchy is the terminal crumb — plain text, aria-current.
|
||||||
|
const hierarchy = screen.getByText('Hierarchy');
|
||||||
|
expect(hierarchy.tagName).toBe('SPAN');
|
||||||
|
expect(hierarchy).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses authSubsegmentLabels for /auth/* paths', () => {
|
||||||
|
renderAt('/auth/oidc/providers');
|
||||||
|
expect(screen.getByText('Access')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('OIDC')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Providers')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders the last crumb as aria-current='page' plain text", () => {
|
||||||
|
renderAt('/certificates/cert-001');
|
||||||
|
const detail = screen.getByText('Detail');
|
||||||
|
expect(detail.tagName).toBe('SPAN');
|
||||||
|
expect(detail).toHaveAttribute('aria-current', 'page');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders intermediate crumbs as <Link> elements pointing at their pathname', () => {
|
||||||
|
renderAt('/certificates/cert-001');
|
||||||
|
const home = screen.getByText('Home');
|
||||||
|
const homeAnchor = home.closest('a');
|
||||||
|
expect(homeAnchor).not.toBeNull();
|
||||||
|
expect(homeAnchor!.getAttribute('href')).toBe('/');
|
||||||
|
|
||||||
|
const certs = screen.getByText('Certificates');
|
||||||
|
const certsAnchor = certs.closest('a');
|
||||||
|
expect(certsAnchor).not.toBeNull();
|
||||||
|
expect(certsAnchor!.getAttribute('href')).toBe('/certificates');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes nav[aria-label="Breadcrumb"] for screen readers', () => {
|
||||||
|
renderAt('/issuers');
|
||||||
|
expect(
|
||||||
|
screen.getByRole('navigation', { name: 'Breadcrumb' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Breadcrumbs — Phase 3 closure for UX-M5 (zero breadcrumb component,
|
||||||
|
// zero navigate(-1), 3-deep routes like issuers/:id/hierarchy have no
|
||||||
|
// wayfinding).
|
||||||
|
//
|
||||||
|
// Implementation note: the audit prompt suggested useMatches() + per-
|
||||||
|
// route handle.crumb. That requires React Router v6's data-router
|
||||||
|
// (createBrowserRouter), but the certctl app currently uses the JSX
|
||||||
|
// <BrowserRouter> form. Migrating the router config is its own
|
||||||
|
// phase-sized effort with non-trivial blast radius (every Route
|
||||||
|
// element, every test's MemoryRouter wrapper). Instead, this version
|
||||||
|
// uses useLocation() to read the current pathname + walks the
|
||||||
|
// segments, mapping each one to a label via the static
|
||||||
|
// pathSegmentLabels lookup below. Limitations: only the top-level +
|
||||||
|
// detail-route segments get a label (anything matching /:id/.../ at a
|
||||||
|
// depth > 2 falls back to the literal segment). Sufficient for the
|
||||||
|
// 3-deep routes the audit flagged (e.g. /issuers/:id/hierarchy);
|
||||||
|
// upgrading to data-router-driven crumbs is a future task once the
|
||||||
|
// router migration ships.
|
||||||
|
|
||||||
|
import { Link, useLocation, useInRouterContext } from 'react-router-dom';
|
||||||
|
import { ChevronRight } from 'lucide-react';
|
||||||
|
|
||||||
|
// pathSegmentLabels — map first-segment URL keys to human labels.
|
||||||
|
// Add entries here as new top-level routes land. Lookup is exact-
|
||||||
|
// match on the first path segment; subsequent segments are heuristics
|
||||||
|
// (see crumbsFor below).
|
||||||
|
const pathSegmentLabels: Record<string, string> = {
|
||||||
|
certificates: 'Certificates',
|
||||||
|
issuers: 'Issuers',
|
||||||
|
agents: 'Agents',
|
||||||
|
targets: 'Targets',
|
||||||
|
jobs: 'Jobs',
|
||||||
|
notifications: 'Notifications',
|
||||||
|
policies: 'Policies',
|
||||||
|
'renewal-policies': 'Renewal Policies',
|
||||||
|
profiles: 'Profiles',
|
||||||
|
owners: 'Owners',
|
||||||
|
teams: 'Teams',
|
||||||
|
'agent-groups': 'Agent Groups',
|
||||||
|
audit: 'Audit Trail',
|
||||||
|
'short-lived': 'Short-Lived',
|
||||||
|
fleet: 'Fleet Overview',
|
||||||
|
discovery: 'Discovery',
|
||||||
|
'network-scans': 'Network Scans',
|
||||||
|
'health-monitor': 'Health Monitor',
|
||||||
|
digest: 'Digest',
|
||||||
|
observability: 'Observability',
|
||||||
|
scep: 'SCEP Admin',
|
||||||
|
est: 'EST Admin',
|
||||||
|
auth: 'Access',
|
||||||
|
};
|
||||||
|
|
||||||
|
// Auth-subtree subsegments (e.g. /auth/oidc/providers).
|
||||||
|
const authSubsegmentLabels: Record<string, string> = {
|
||||||
|
oidc: 'OIDC',
|
||||||
|
providers: 'Providers',
|
||||||
|
sessions: 'Sessions',
|
||||||
|
users: 'Users',
|
||||||
|
roles: 'Roles',
|
||||||
|
keys: 'API Keys',
|
||||||
|
approvals: 'Approvals',
|
||||||
|
breakglass: 'Break-glass',
|
||||||
|
settings: 'Auth Settings',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Crumb {
|
||||||
|
pathname: string;
|
||||||
|
label: string;
|
||||||
|
isLast: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
function crumbsFor(pathname: string): Crumb[] {
|
||||||
|
// Dashboard root produces no breadcrumb trail — the title alone
|
||||||
|
// suffices.
|
||||||
|
if (pathname === '/' || pathname === '') return [];
|
||||||
|
|
||||||
|
const segments = pathname.split('/').filter(Boolean);
|
||||||
|
if (segments.length === 0) return [];
|
||||||
|
|
||||||
|
// The Dashboard ("Home") crumb is always the first hop.
|
||||||
|
const out: Crumb[] = [{ pathname: '/', label: 'Home', isLast: false }];
|
||||||
|
|
||||||
|
// First segment — top-level route.
|
||||||
|
const first = segments[0]!;
|
||||||
|
const firstLabel = pathSegmentLabels[first] ?? first;
|
||||||
|
out.push({
|
||||||
|
pathname: '/' + first,
|
||||||
|
label: firstLabel,
|
||||||
|
isLast: segments.length === 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Subsequent segments — heuristics:
|
||||||
|
// - /auth/<sub>[/...] uses authSubsegmentLabels for each piece
|
||||||
|
// - any other segment that looks like an :id (starts with a
|
||||||
|
// known prefix or is hex/random) becomes "Detail"
|
||||||
|
// - terminal /hierarchy on /issuers/:id/hierarchy → "Hierarchy"
|
||||||
|
let acc = '/' + first;
|
||||||
|
for (let i = 1; i < segments.length; i++) {
|
||||||
|
const seg = segments[i]!;
|
||||||
|
acc += '/' + seg;
|
||||||
|
let label: string;
|
||||||
|
if (first === 'auth') {
|
||||||
|
label = authSubsegmentLabels[seg] ?? seg;
|
||||||
|
} else if (seg === 'hierarchy') {
|
||||||
|
label = 'Hierarchy';
|
||||||
|
} else if (looksLikeID(seg)) {
|
||||||
|
label = 'Detail';
|
||||||
|
} else {
|
||||||
|
label = seg;
|
||||||
|
}
|
||||||
|
out.push({ pathname: acc, label, isLast: i === segments.length - 1 });
|
||||||
|
}
|
||||||
|
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** ID-shape heuristic — certctl IDs look like cert-001, iss-vault, t-iis-prod. */
|
||||||
|
function looksLikeID(s: string): boolean {
|
||||||
|
// Anything with a hyphen is treated as an ID for breadcrumb purposes.
|
||||||
|
// Hyphenated segments that aren't IDs (renewal-policies, agent-groups,
|
||||||
|
// network-scans, health-monitor, short-lived) are top-level routes
|
||||||
|
// resolved by pathSegmentLabels BEFORE this heuristic fires.
|
||||||
|
return s.includes('-') || /^[a-f0-9]{8,}$/i.test(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Breadcrumbs is the public entry. Defensive against missing Router
|
||||||
|
// context (a test that mounts a PageHeader without a <MemoryRouter>
|
||||||
|
// wrapper used to crash here). useLocation() throws an invariant
|
||||||
|
// error if there's no Router; gate it behind useInRouterContext()
|
||||||
|
// + render the actual logic in a sibling so useLocation() is only
|
||||||
|
// called when we know the context is present.
|
||||||
|
export default function Breadcrumbs() {
|
||||||
|
const inRouter = useInRouterContext();
|
||||||
|
if (!inRouter) return null;
|
||||||
|
return <BreadcrumbsInner />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function BreadcrumbsInner() {
|
||||||
|
const { pathname } = useLocation();
|
||||||
|
const crumbs = crumbsFor(pathname);
|
||||||
|
|
||||||
|
if (crumbs.length === 0) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav aria-label="Breadcrumb" className="mb-1">
|
||||||
|
<ol className="flex items-center gap-1 text-xs text-ink-muted">
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<li key={c.pathname} className="flex items-center gap-1">
|
||||||
|
{i > 0 && (
|
||||||
|
<ChevronRight
|
||||||
|
className="w-3 h-3 text-ink-faint shrink-0"
|
||||||
|
strokeWidth={1.5}
|
||||||
|
aria-hidden="true"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{c.isLast ? (
|
||||||
|
<span aria-current="page" className="text-ink font-medium">
|
||||||
|
{c.label}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Link
|
||||||
|
to={c.pathname}
|
||||||
|
className="hover:text-brand-500 hover:underline transition-colors"
|
||||||
|
>
|
||||||
|
{c.label}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ol>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import Combobox from './Combobox';
|
||||||
|
|
||||||
|
type Option = { id: string; name: string };
|
||||||
|
|
||||||
|
const OPTIONS: Option[] = [
|
||||||
|
{ id: 'iss-vault', name: 'Vault PKI' },
|
||||||
|
{ id: 'iss-acme', name: 'ACME (Let\'s Encrypt)' },
|
||||||
|
{ id: 'iss-local', name: 'Local CA' },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe('Combobox', () => {
|
||||||
|
it('renders the input', () => {
|
||||||
|
render(
|
||||||
|
<Combobox<Option>
|
||||||
|
value={null}
|
||||||
|
onChange={() => {}}
|
||||||
|
options={OPTIONS}
|
||||||
|
getKey={(o) => o.id}
|
||||||
|
getLabel={(o) => o.name}
|
||||||
|
placeholder="Pick issuer"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByPlaceholderText('Pick issuer')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders the selected value as the input display', () => {
|
||||||
|
render(
|
||||||
|
<Combobox<Option>
|
||||||
|
value={OPTIONS[2]}
|
||||||
|
onChange={() => {}}
|
||||||
|
options={OPTIONS}
|
||||||
|
getKey={(o) => o.id}
|
||||||
|
getLabel={(o) => o.name}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByDisplayValue('Local CA')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters options as the operator types', () => {
|
||||||
|
render(
|
||||||
|
<Combobox<Option>
|
||||||
|
value={null}
|
||||||
|
onChange={() => {}}
|
||||||
|
options={OPTIONS}
|
||||||
|
getKey={(o) => o.id}
|
||||||
|
getLabel={(o) => o.name}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
fireEvent.change(input, { target: { value: 'vault' } });
|
||||||
|
expect(screen.getByText('Vault PKI')).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Local CA')).not.toBeInTheDocument();
|
||||||
|
expect(screen.queryByText("ACME (Let's Encrypt)")).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onChange when the operator selects via keyboard', () => {
|
||||||
|
const onChange = vi.fn();
|
||||||
|
render(
|
||||||
|
<Combobox<Option>
|
||||||
|
value={null}
|
||||||
|
onChange={onChange}
|
||||||
|
options={OPTIONS}
|
||||||
|
getKey={(o) => o.id}
|
||||||
|
getLabel={(o) => o.name}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
// Open the listbox + filter to a single option, then press Enter.
|
||||||
|
// Click-to-select on Headless UI requires the pointerdown sequence
|
||||||
|
// which @testing-library/dom's fireEvent doesn't synthesize; the
|
||||||
|
// keyboard path is the accessible-equivalent and is what screen
|
||||||
|
// reader / keyboard-only operators use anyway.
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.change(input, { target: { value: 'Local' } });
|
||||||
|
fireEvent.keyDown(input, { key: 'ArrowDown' });
|
||||||
|
fireEvent.keyDown(input, { key: 'Enter' });
|
||||||
|
expect(onChange).toHaveBeenCalledWith(OPTIONS[2]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "No matches" when the filter excludes everything', () => {
|
||||||
|
render(
|
||||||
|
<Combobox<Option>
|
||||||
|
value={null}
|
||||||
|
onChange={() => {}}
|
||||||
|
options={OPTIONS}
|
||||||
|
getKey={(o) => o.id}
|
||||||
|
getLabel={(o) => o.name}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const input = screen.getByRole('combobox');
|
||||||
|
fireEvent.focus(input);
|
||||||
|
fireEvent.change(input, { target: { value: 'nonexistent' } });
|
||||||
|
expect(screen.getByText('No matches.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Combobox — Headless UI-backed typeahead select primitive. Phase 1
|
||||||
|
// closure for UX-M4 (~53 native HTML <select> elements with no
|
||||||
|
// typeahead surface). Migrating callsites is per-page rolling work
|
||||||
|
// in subsequent PRs; Phase 1 builds the primitive.
|
||||||
|
//
|
||||||
|
// Compared with native <select>:
|
||||||
|
// - typeahead filter narrows options as the operator types
|
||||||
|
// - keyboard nav (Up/Down/Enter/Esc) handled by Headless UI
|
||||||
|
// - aria-expanded / aria-activedescendant / aria-labelledby wired
|
||||||
|
// for free
|
||||||
|
// - styled to match the certctl .input + .card token palette
|
||||||
|
//
|
||||||
|
// Generic on the option value type T (string IDs are typical; arbitrary
|
||||||
|
// objects work too — supply a `getKey` + `getLabel`).
|
||||||
|
|
||||||
|
import { useState, useMemo } from 'react';
|
||||||
|
import { Combobox as HeadlessCombobox } from '@headlessui/react';
|
||||||
|
|
||||||
|
export interface ComboboxProps<T> {
|
||||||
|
/** The currently-selected option, or null if none. */
|
||||||
|
value: T | null;
|
||||||
|
/** Fires when the operator picks an option. */
|
||||||
|
onChange: (next: T | null) => void;
|
||||||
|
/** Full options list — Combobox filters internally on typed query. */
|
||||||
|
options: T[];
|
||||||
|
/** Stable string key per option (used for React `key` + filter equality). */
|
||||||
|
getKey: (option: T) => string;
|
||||||
|
/** Human-readable label rendered in the input + dropdown row. */
|
||||||
|
getLabel: (option: T) => string;
|
||||||
|
/** Optional placeholder when no value is selected. */
|
||||||
|
placeholder?: string;
|
||||||
|
/** Optional `id` on the input element (label wiring). */
|
||||||
|
inputId?: string;
|
||||||
|
/** Disabled state. */
|
||||||
|
disabled?: boolean;
|
||||||
|
/** Extra className on the outer wrapper. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Combobox<T>({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
options,
|
||||||
|
getKey,
|
||||||
|
getLabel,
|
||||||
|
placeholder,
|
||||||
|
inputId,
|
||||||
|
disabled,
|
||||||
|
className = '',
|
||||||
|
}: ComboboxProps<T>) {
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
|
||||||
|
// Filter is local + case-insensitive substring against the label.
|
||||||
|
// For >1000-option lists this should move to server-side; not Phase
|
||||||
|
// 1's problem.
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
if (!query) return options;
|
||||||
|
const needle = query.toLowerCase();
|
||||||
|
return options.filter((o) => getLabel(o).toLowerCase().includes(needle));
|
||||||
|
}, [options, query, getLabel]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<HeadlessCombobox
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
disabled={disabled}
|
||||||
|
>
|
||||||
|
<div className={`relative ${className}`}>
|
||||||
|
<HeadlessCombobox.Input
|
||||||
|
id={inputId}
|
||||||
|
className="input w-full"
|
||||||
|
placeholder={placeholder}
|
||||||
|
displayValue={(o: T | null) => (o ? getLabel(o) : '')}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
/>
|
||||||
|
<HeadlessCombobox.Options
|
||||||
|
className="absolute z-30 mt-1 max-h-60 w-full overflow-auto rounded border border-surface-border bg-surface shadow-lg focus:outline-none"
|
||||||
|
>
|
||||||
|
{filtered.length === 0 && query !== '' && (
|
||||||
|
<div className="px-3 py-2 text-sm text-ink-faint">
|
||||||
|
No matches.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{filtered.map((option) => (
|
||||||
|
<HeadlessCombobox.Option
|
||||||
|
key={getKey(option)}
|
||||||
|
value={option}
|
||||||
|
className={({ active, selected }) =>
|
||||||
|
`cursor-pointer px-3 py-2 text-sm ${
|
||||||
|
active ? 'bg-brand-50 text-brand-700' : 'text-ink'
|
||||||
|
} ${selected ? 'font-semibold' : ''}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{getLabel(option)}
|
||||||
|
</HeadlessCombobox.Option>
|
||||||
|
))}
|
||||||
|
</HeadlessCombobox.Options>
|
||||||
|
</div>
|
||||||
|
</HeadlessCombobox>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// CommandPalette — Phase 3 closure for UX-H6 (no cmd+k palette, no
|
||||||
|
// <input type="search">, no global keyboard-shortcut surface) and
|
||||||
|
// FE-L4 (rolls under UX-H6 per the audit's framing).
|
||||||
|
//
|
||||||
|
// Built on `cmdk`. Three sections:
|
||||||
|
//
|
||||||
|
// 1. Navigation — every route surfaced in Layout.tsx's navGroups.
|
||||||
|
// Operator types "audit", picks the matching row, navigates to
|
||||||
|
// /audit. Reproduces a sidebar without the scroll.
|
||||||
|
// 2. Actions — quick-fire operations that aren't routes: "Issue
|
||||||
|
// new certificate" (navigates to / + ?onboarding=1), "Create
|
||||||
|
// issuer", "Trigger discovery scan". Each action is a callback
|
||||||
|
// that closes the palette.
|
||||||
|
// 3. Server-search — debounced fetch against /api/v1/certificates?q=
|
||||||
|
// + /api/v1/issuers?q= for typeahead across cert names + issuer
|
||||||
|
// names. Results stream into the same cmdk list under a "Search
|
||||||
|
// results" heading; clicking jumps to that record's detail page.
|
||||||
|
//
|
||||||
|
// Global keydown listener (meta+k on macOS, ctrl+k everywhere else)
|
||||||
|
// is wired in web/src/main.tsx — the palette itself is render-only
|
||||||
|
// and reads `open` from a prop.
|
||||||
|
|
||||||
|
import { Command } from 'cmdk';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
LayoutDashboard, ShieldCheck, Search, Server, Network, Radar, Timer,
|
||||||
|
KeyRound, FileText, ScrollText, RefreshCw, Wrench,
|
||||||
|
Target, ListTodo, HeartPulse,
|
||||||
|
User, Users, Group,
|
||||||
|
Bell, Inbox, Activity,
|
||||||
|
Clock, UserCog, CheckCircle2, AlertTriangle, Cog,
|
||||||
|
Plus, Zap,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
import { getCertificates, getIssuers } from '../api/client';
|
||||||
|
import type { Certificate, Issuer } from '../api/types';
|
||||||
|
|
||||||
|
export interface CommandPaletteProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NavCommand {
|
||||||
|
to: string;
|
||||||
|
label: string;
|
||||||
|
group: string;
|
||||||
|
icon: LucideIcon;
|
||||||
|
}
|
||||||
|
|
||||||
|
// NAV_COMMANDS — flattened view of Layout.tsx's navGroups, kept in
|
||||||
|
// sync by hand. (DRY-ing this against the Layout would require an
|
||||||
|
// extra module just to share the table; the audit notes future work
|
||||||
|
// could collapse them.)
|
||||||
|
const NAV_COMMANDS: NavCommand[] = [
|
||||||
|
// Inventory
|
||||||
|
{ to: '/', label: 'Dashboard', group: 'Inventory', icon: LayoutDashboard },
|
||||||
|
{ to: '/certificates', label: 'Certificates', group: 'Inventory', icon: ShieldCheck },
|
||||||
|
{ to: '/discovery', label: 'Discovery', group: 'Inventory', icon: Search },
|
||||||
|
{ to: '/agents', label: 'Agents', group: 'Inventory', icon: Server },
|
||||||
|
{ to: '/fleet', label: 'Fleet Overview', group: 'Inventory', icon: Network },
|
||||||
|
{ to: '/network-scans', label: 'Network Scans', group: 'Inventory', icon: Radar },
|
||||||
|
{ to: '/short-lived', label: 'Short-Lived', group: 'Inventory', icon: Timer },
|
||||||
|
// Trust
|
||||||
|
{ to: '/issuers', label: 'Issuers', group: 'Trust', icon: KeyRound },
|
||||||
|
{ to: '/profiles', label: 'Profiles', group: 'Trust', icon: FileText },
|
||||||
|
{ to: '/policies', label: 'Policies', group: 'Trust', icon: ScrollText },
|
||||||
|
{ to: '/renewal-policies', label: 'Renewal Policies', group: 'Trust', icon: RefreshCw },
|
||||||
|
{ to: '/scep', label: 'SCEP Admin', group: 'Trust', icon: Wrench },
|
||||||
|
{ to: '/est', label: 'EST Admin', group: 'Trust', icon: Wrench },
|
||||||
|
// Delivery
|
||||||
|
{ to: '/targets', label: 'Targets', group: 'Delivery', icon: Target },
|
||||||
|
{ to: '/jobs', label: 'Jobs', group: 'Delivery', icon: ListTodo },
|
||||||
|
{ to: '/health-monitor', label: 'Health Monitor', group: 'Delivery', icon: HeartPulse },
|
||||||
|
// People
|
||||||
|
{ to: '/owners', label: 'Owners', group: 'People', icon: User },
|
||||||
|
{ to: '/teams', label: 'Teams', group: 'People', icon: Users },
|
||||||
|
{ to: '/agent-groups', label: 'Agent Groups', group: 'People', icon: Group },
|
||||||
|
// Notify
|
||||||
|
{ to: '/notifications', label: 'Notifications', group: 'Notify', icon: Bell },
|
||||||
|
{ to: '/digest', label: 'Digest', group: 'Notify', icon: Inbox },
|
||||||
|
{ to: '/observability', label: 'Observability', group: 'Notify', icon: Activity },
|
||||||
|
// Access
|
||||||
|
{ to: '/auth/oidc/providers', label: 'OIDC Providers', group: 'Access', icon: ShieldCheck },
|
||||||
|
{ to: '/auth/sessions', label: 'Sessions', group: 'Access', icon: Clock },
|
||||||
|
{ to: '/auth/users', label: 'Users', group: 'Access', icon: Users },
|
||||||
|
{ to: '/auth/roles', label: 'Roles', group: 'Access', icon: UserCog },
|
||||||
|
{ to: '/auth/keys', label: 'API Keys', group: 'Access', icon: KeyRound },
|
||||||
|
{ to: '/auth/approvals', label: 'Approvals', group: 'Access', icon: CheckCircle2 },
|
||||||
|
{ to: '/auth/breakglass', label: 'Break-glass', group: 'Access', icon: AlertTriangle },
|
||||||
|
{ to: '/auth/settings', label: 'Auth Settings', group: 'Access', icon: Cog },
|
||||||
|
// Audit
|
||||||
|
{ to: '/audit', label: 'Audit Trail', group: 'Audit', icon: ScrollText },
|
||||||
|
];
|
||||||
|
|
||||||
|
interface SearchResult {
|
||||||
|
type: 'certificate' | 'issuer';
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
to: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useDebouncedValue — small hook to throttle the server-search query
|
||||||
|
* so we don't fire a fetch on every keystroke.
|
||||||
|
*/
|
||||||
|
function useDebouncedValue<T>(value: T, ms: number): T {
|
||||||
|
const [debounced, setDebounced] = useState(value);
|
||||||
|
useEffect(() => {
|
||||||
|
const t = setTimeout(() => setDebounced(value), ms);
|
||||||
|
return () => clearTimeout(t);
|
||||||
|
}, [value, ms]);
|
||||||
|
return debounced;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CommandPalette({ open, onOpenChange }: CommandPaletteProps) {
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const [query, setQuery] = useState('');
|
||||||
|
const debouncedQuery = useDebouncedValue(query, 250);
|
||||||
|
const [serverResults, setServerResults] = useState<SearchResult[]>([]);
|
||||||
|
|
||||||
|
// Server-search on debounced input. Empty / <2-char queries skip
|
||||||
|
// the fetch (too many results to be useful + load on the API).
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || debouncedQuery.length < 2) {
|
||||||
|
setServerResults([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let cancelled = false;
|
||||||
|
(async () => {
|
||||||
|
try {
|
||||||
|
const [certsResp, issuersResp] = await Promise.all([
|
||||||
|
getCertificates({ q: debouncedQuery, per_page: '8' }),
|
||||||
|
getIssuers({ q: debouncedQuery, per_page: '8' }),
|
||||||
|
]);
|
||||||
|
if (cancelled) return;
|
||||||
|
const certs: SearchResult[] = (certsResp?.data ?? []).map((c: Certificate) => ({
|
||||||
|
type: 'certificate',
|
||||||
|
id: c.id,
|
||||||
|
label: c.common_name || c.id,
|
||||||
|
to: `/certificates/${c.id}`,
|
||||||
|
}));
|
||||||
|
const issuers: SearchResult[] = (issuersResp?.data ?? []).map((i: Issuer) => ({
|
||||||
|
type: 'issuer',
|
||||||
|
id: i.id,
|
||||||
|
label: i.name || i.id,
|
||||||
|
to: `/issuers/${i.id}`,
|
||||||
|
}));
|
||||||
|
setServerResults([...certs, ...issuers]);
|
||||||
|
} catch {
|
||||||
|
// Silent — keep whatever's already in the list.
|
||||||
|
if (!cancelled) setServerResults([]);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [debouncedQuery, open]);
|
||||||
|
|
||||||
|
// Reset query each time the palette opens — fresh state per session.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setQuery('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const navByGroup = useMemo(() => {
|
||||||
|
const m = new Map<string, NavCommand[]>();
|
||||||
|
for (const n of NAV_COMMANDS) {
|
||||||
|
if (!m.has(n.group)) m.set(n.group, []);
|
||||||
|
m.get(n.group)!.push(n);
|
||||||
|
}
|
||||||
|
return m;
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const go = (to: string) => {
|
||||||
|
onOpenChange(false);
|
||||||
|
navigate(to);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Command.Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
label="Global command palette"
|
||||||
|
className="fixed inset-0 z-50 flex items-start justify-center pt-24"
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 bg-black/40"
|
||||||
|
aria-hidden="true"
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Panel */}
|
||||||
|
<div className="relative w-full max-w-xl bg-surface border border-surface-border rounded-lg shadow-2xl overflow-hidden">
|
||||||
|
<Command.Input
|
||||||
|
autoFocus
|
||||||
|
value={query}
|
||||||
|
onValueChange={setQuery}
|
||||||
|
placeholder="Type a page name, action, or search certs / issuers…"
|
||||||
|
className="w-full px-4 py-3 text-sm text-ink bg-transparent border-b border-surface-border focus:outline-none placeholder:text-ink-faint"
|
||||||
|
/>
|
||||||
|
<Command.List className="max-h-96 overflow-y-auto py-1">
|
||||||
|
<Command.Empty className="px-4 py-6 text-center text-sm text-ink-faint">
|
||||||
|
No matches — try a different term.
|
||||||
|
</Command.Empty>
|
||||||
|
|
||||||
|
{/* Navigation — every sidebar item, grouped */}
|
||||||
|
{Array.from(navByGroup.entries()).map(([groupName, items]) => (
|
||||||
|
<Command.Group key={groupName} heading={groupName}>
|
||||||
|
{items.map((item) => {
|
||||||
|
const I = item.icon;
|
||||||
|
return (
|
||||||
|
<Command.Item
|
||||||
|
key={item.to}
|
||||||
|
value={`${groupName} ${item.label}`}
|
||||||
|
onSelect={() => go(item.to)}
|
||||||
|
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
|
||||||
|
>
|
||||||
|
<I className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>{item.label}</span>
|
||||||
|
</Command.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Command.Group>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Actions — quick-fire operations that aren't routes */}
|
||||||
|
<Command.Group heading="Actions">
|
||||||
|
<Command.Item
|
||||||
|
value="action issue new certificate"
|
||||||
|
onSelect={() => go('/?onboarding=1')}
|
||||||
|
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>Issue new certificate (Setup guide)</span>
|
||||||
|
</Command.Item>
|
||||||
|
<Command.Item
|
||||||
|
value="action create issuer"
|
||||||
|
onSelect={() => go('/issuers')}
|
||||||
|
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
|
||||||
|
>
|
||||||
|
<KeyRound className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>Create issuer…</span>
|
||||||
|
</Command.Item>
|
||||||
|
<Command.Item
|
||||||
|
value="action trigger discovery scan"
|
||||||
|
onSelect={() => go('/network-scans')}
|
||||||
|
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
|
||||||
|
>
|
||||||
|
<Zap className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
<span>Trigger discovery scan…</span>
|
||||||
|
</Command.Item>
|
||||||
|
</Command.Group>
|
||||||
|
|
||||||
|
{/* Server search — only render the heading if we have hits */}
|
||||||
|
{serverResults.length > 0 && (
|
||||||
|
<Command.Group heading="Search results">
|
||||||
|
{serverResults.map((r) => (
|
||||||
|
<Command.Item
|
||||||
|
key={`${r.type}-${r.id}`}
|
||||||
|
value={`search ${r.label} ${r.id}`}
|
||||||
|
onSelect={() => go(r.to)}
|
||||||
|
className="px-4 py-2 text-sm text-ink cursor-pointer flex items-center gap-3 data-[selected=true]:bg-brand-50 data-[selected=true]:text-brand-700"
|
||||||
|
>
|
||||||
|
{r.type === 'certificate'
|
||||||
|
? <ShieldCheck className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
: <KeyRound className="w-4 h-4 shrink-0 text-ink-muted" strokeWidth={1.75} aria-hidden="true" />}
|
||||||
|
<span className="flex-1">{r.label}</span>
|
||||||
|
<span className="text-xs text-ink-faint capitalize">{r.type}</span>
|
||||||
|
</Command.Item>
|
||||||
|
))}
|
||||||
|
</Command.Group>
|
||||||
|
)}
|
||||||
|
</Command.List>
|
||||||
|
|
||||||
|
{/* Footer hint */}
|
||||||
|
<div className="px-4 py-2 border-t border-surface-border text-xs text-ink-faint flex items-center justify-between">
|
||||||
|
<span>↑↓ navigate · ↵ select · esc close</span>
|
||||||
|
<span><kbd className="px-1 py-0.5 text-2xs bg-surface-muted border border-surface-border rounded">⌘K</kbd></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Command.Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// CommandPaletteHost — Phase 3 closure: thin wrapper around
|
||||||
|
// CommandPalette that owns the open/close state + the global
|
||||||
|
// keyboard listener (meta+k on mac, ctrl+k everywhere else).
|
||||||
|
//
|
||||||
|
// Lives at the React tree root (mounted alongside Toaster in
|
||||||
|
// main.tsx) so the keydown handler is registered once + survives
|
||||||
|
// page navigations. The handler is intentionally scoped to the
|
||||||
|
// component lifecycle so HMR + React StrictMode double-mount don't
|
||||||
|
// leave orphaned listeners.
|
||||||
|
|
||||||
|
import { useEffect, useState, lazy, Suspense } from 'react';
|
||||||
|
|
||||||
|
// Lazy-load the palette so cmdk's bundle (~25 KB) doesn't land on
|
||||||
|
// the initial page load — only fetched once the operator hits cmd+k.
|
||||||
|
const CommandPalette = lazy(() => import('./CommandPalette'));
|
||||||
|
|
||||||
|
export default function CommandPaletteHost() {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handler = (e: KeyboardEvent) => {
|
||||||
|
// metaKey on macOS, ctrlKey on Windows / Linux.
|
||||||
|
const isCmdK = e.key === 'k' && (e.metaKey || e.ctrlKey);
|
||||||
|
if (isCmdK) {
|
||||||
|
e.preventDefault();
|
||||||
|
setOpen((prev) => !prev);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('keydown', handler);
|
||||||
|
return () => document.removeEventListener('keydown', handler);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Only mount the palette tree when first-needed — avoids fetching
|
||||||
|
// cmdk's bundle on every page load.
|
||||||
|
if (!open) return null;
|
||||||
|
return (
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
<CommandPalette open={open} onOpenChange={setOpen} />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Smoke + behavior tests for ConfirmDialog. The primitive replaces
|
||||||
|
// window.confirm(); the test suite asserts the contract:
|
||||||
|
// - hidden when open=false
|
||||||
|
// - title + message render
|
||||||
|
// - ESC + backdrop click + cancel button → onCancel
|
||||||
|
// - confirm button → onConfirm
|
||||||
|
// - typedConfirmation gates the confirm button until the exact string
|
||||||
|
// is typed
|
||||||
|
// - destructive=true uses the btn-danger styling
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
|
|
||||||
|
describe('ConfirmDialog', () => {
|
||||||
|
it('does not render when open=false', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open={false}
|
||||||
|
title="Archive cert"
|
||||||
|
message="Cannot be undone."
|
||||||
|
onConfirm={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Archive cert')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title + message when open=true', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Archive cert"
|
||||||
|
message="Cannot be undone."
|
||||||
|
onConfirm={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Archive cert')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('Cannot be undone.')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onConfirm when confirm button clicked', () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Delete owner"
|
||||||
|
message="Bob will be removed."
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /confirm/i }));
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fires onCancel when cancel button clicked', () => {
|
||||||
|
const onCancel = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Delete owner"
|
||||||
|
message="Bob will be removed."
|
||||||
|
onConfirm={() => {}}
|
||||||
|
onCancel={onCancel}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: /cancel/i }));
|
||||||
|
expect(onCancel).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disables confirm button until typedConfirmation matches', () => {
|
||||||
|
const onConfirm = vi.fn();
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Archive cert"
|
||||||
|
message="Type DELETE to confirm."
|
||||||
|
typedConfirmation="DELETE"
|
||||||
|
onConfirm={onConfirm}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
|
||||||
|
expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
const input = screen.getByLabelText(/Type/i);
|
||||||
|
fireEvent.change(input, { target: { value: 'wrong' } });
|
||||||
|
expect(confirmBtn).toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.change(input, { target: { value: 'DELETE' } });
|
||||||
|
expect(confirmBtn).not.toBeDisabled();
|
||||||
|
|
||||||
|
fireEvent.click(confirmBtn);
|
||||||
|
expect(onConfirm).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('uses btn-danger styling when destructive=true', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Revoke cert"
|
||||||
|
message="Cannot be undone."
|
||||||
|
destructive
|
||||||
|
onConfirm={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
const confirmBtn = screen.getByRole('button', { name: /confirm/i });
|
||||||
|
expect(confirmBtn.className).toContain('btn-danger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('honours custom confirmLabel + cancelLabel', () => {
|
||||||
|
render(
|
||||||
|
<ConfirmDialog
|
||||||
|
open
|
||||||
|
title="Archive cert"
|
||||||
|
message="Are you sure?"
|
||||||
|
confirmLabel="Yes, archive"
|
||||||
|
cancelLabel="No, go back"
|
||||||
|
onConfirm={() => {}}
|
||||||
|
onCancel={() => {}}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Yes, archive' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'No, go back' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// ConfirmDialog — the certctl-themed replacement for window.confirm().
|
||||||
|
// Phase 1 closure for UX-H2 (destructive actions use window.confirm).
|
||||||
|
//
|
||||||
|
// Built on Headless UI's <Dialog>, which gives us:
|
||||||
|
// - automatic focus trap (Tab/Shift-Tab stays inside the modal)
|
||||||
|
// - automatic ESC-to-close (we wire onCancel to it)
|
||||||
|
// - automatic backdrop-click-to-close (we wire onCancel to it)
|
||||||
|
// - role="dialog" + aria-modal="true" on the panel
|
||||||
|
// - aria-labelledby on the title node, aria-describedby on the body
|
||||||
|
// - <Transition> handles enter/exit; respects prefers-reduced-motion
|
||||||
|
// transparently via the @media block in src/index.css.
|
||||||
|
//
|
||||||
|
// Optional `typedConfirmation` raises the friction for the most
|
||||||
|
// irreversible actions. Passing `typedConfirmation: "delete"` requires
|
||||||
|
// the operator to literally type the string "delete" into a field
|
||||||
|
// before the confirm button enables. Reserve it for the worst-case
|
||||||
|
// actions: archive-this-certificate, delete-root-CA, etc.
|
||||||
|
//
|
||||||
|
// Visual posture: destructive variant uses red surface tints + a red
|
||||||
|
// confirm button matching .btn-danger. Non-destructive uses the
|
||||||
|
// default brand-teal confirm button.
|
||||||
|
|
||||||
|
import { Fragment, useState, useEffect, useRef } from 'react';
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
|
|
||||||
|
export interface ConfirmDialogProps {
|
||||||
|
/** Controls visibility. Parent owns the boolean. */
|
||||||
|
open: boolean;
|
||||||
|
/** Title shown at the top of the dialog. Concise: "Archive certificate". */
|
||||||
|
title: string;
|
||||||
|
/** Body copy. Plain text recommended; spell out consequences. */
|
||||||
|
message: string;
|
||||||
|
/** Label for the confirm button. Defaults to "Confirm". */
|
||||||
|
confirmLabel?: string;
|
||||||
|
/** Label for the cancel button. Defaults to "Cancel". */
|
||||||
|
cancelLabel?: string;
|
||||||
|
/** When true, confirm button uses .btn-danger styling. */
|
||||||
|
destructive?: boolean;
|
||||||
|
/**
|
||||||
|
* When set, the operator must type this exact string before the
|
||||||
|
* confirm button enables. Use for the most irreversible actions
|
||||||
|
* (archive certificate, delete CA, etc.).
|
||||||
|
*/
|
||||||
|
typedConfirmation?: string;
|
||||||
|
/** Fires when the confirm button is clicked. Parent closes the dialog. */
|
||||||
|
onConfirm: () => void;
|
||||||
|
/** Fires on ESC, backdrop click, or cancel button. */
|
||||||
|
onCancel: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConfirmDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
message,
|
||||||
|
confirmLabel = 'Confirm',
|
||||||
|
cancelLabel = 'Cancel',
|
||||||
|
destructive = false,
|
||||||
|
typedConfirmation,
|
||||||
|
onConfirm,
|
||||||
|
onCancel,
|
||||||
|
}: ConfirmDialogProps) {
|
||||||
|
const [typedValue, setTypedValue] = useState('');
|
||||||
|
const cancelButtonRef = useRef<HTMLButtonElement>(null);
|
||||||
|
|
||||||
|
// Reset typed-confirmation state every time the dialog closes/reopens.
|
||||||
|
// Without this, a previous successful confirmation leaves the field
|
||||||
|
// pre-filled on the next confirmation prompt — that's a footgun.
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) setTypedValue('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const typedOK = !typedConfirmation || typedValue === typedConfirmation;
|
||||||
|
const confirmDisabled = !typedOK;
|
||||||
|
|
||||||
|
const confirmClass = destructive
|
||||||
|
? 'btn btn-danger'
|
||||||
|
: 'btn btn-primary';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Transition show={open} as={Fragment}>
|
||||||
|
<Dialog
|
||||||
|
as="div"
|
||||||
|
className="relative z-50"
|
||||||
|
onClose={onCancel}
|
||||||
|
initialFocus={cancelButtonRef}
|
||||||
|
>
|
||||||
|
{/* Backdrop */}
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-150"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
<div className="fixed inset-0 overflow-y-auto">
|
||||||
|
<div className="flex min-h-full items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-150"
|
||||||
|
enterFrom="opacity-0 translate-y-2 scale-95"
|
||||||
|
enterTo="opacity-100 translate-y-0 scale-100"
|
||||||
|
leave="ease-in duration-100"
|
||||||
|
leaveFrom="opacity-100 translate-y-0 scale-100"
|
||||||
|
leaveTo="opacity-0 translate-y-2 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel
|
||||||
|
className={`w-full max-w-md transform overflow-hidden rounded-lg bg-surface shadow-xl border ${
|
||||||
|
destructive ? 'border-red-200' : 'border-surface-border'
|
||||||
|
} p-6`}
|
||||||
|
>
|
||||||
|
<Dialog.Title
|
||||||
|
as="h3"
|
||||||
|
className="text-lg font-semibold text-ink"
|
||||||
|
>
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description
|
||||||
|
as="p"
|
||||||
|
className="mt-2 text-sm text-ink-muted"
|
||||||
|
>
|
||||||
|
{message}
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
{typedConfirmation && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<label
|
||||||
|
htmlFor="confirm-typed-input"
|
||||||
|
className="block text-xs font-medium text-ink-muted mb-1"
|
||||||
|
>
|
||||||
|
Type{' '}
|
||||||
|
<code className="text-ink font-mono">
|
||||||
|
{typedConfirmation}
|
||||||
|
</code>{' '}
|
||||||
|
to enable confirmation:
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="confirm-typed-input"
|
||||||
|
type="text"
|
||||||
|
autoComplete="off"
|
||||||
|
autoFocus
|
||||||
|
value={typedValue}
|
||||||
|
onChange={(e) => setTypedValue(e.target.value)}
|
||||||
|
className="input w-full"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
ref={cancelButtonRef}
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={onCancel}
|
||||||
|
>
|
||||||
|
{cancelLabel}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className={confirmClass}
|
||||||
|
onClick={onConfirm}
|
||||||
|
disabled={confirmDisabled}
|
||||||
|
>
|
||||||
|
{confirmLabel}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import Skeleton from './Skeleton';
|
||||||
|
|
||||||
interface Column<T> {
|
interface Column<T> {
|
||||||
key: string;
|
key: string;
|
||||||
label: string;
|
label: string;
|
||||||
@@ -28,6 +31,14 @@ interface DataTableProps<T> {
|
|||||||
data: T[];
|
data: T[];
|
||||||
onRowClick?: (item: T) => void;
|
onRowClick?: (item: T) => void;
|
||||||
emptyMessage?: string;
|
emptyMessage?: string;
|
||||||
|
/**
|
||||||
|
* UX-M3 / Phase 1: rich empty-state slot. Pass an <EmptyState />
|
||||||
|
* component (or any ReactNode) here when the page wants a CTA-driven
|
||||||
|
* first-run experience instead of the bare emptyMessage string. The
|
||||||
|
* existing `emptyMessage` prop is preserved for backward compat with
|
||||||
|
* the ~18 list-page call sites that pass a simple string.
|
||||||
|
*/
|
||||||
|
emptyState?: ReactNode;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
keyField?: string;
|
keyField?: string;
|
||||||
selectable?: boolean;
|
selectable?: boolean;
|
||||||
@@ -36,20 +47,24 @@ interface DataTableProps<T> {
|
|||||||
pagination?: PaginationProps;
|
pagination?: PaginationProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
|
export default function DataTable<T>({ columns, data, onRowClick, emptyMessage, emptyState, isLoading, keyField = 'id', selectable, selectedKeys, onSelectionChange, pagination }: DataTableProps<T>) {
|
||||||
|
// Phase 4 closure (UX-M1): swap the centered spinner + "Loading..."
|
||||||
|
// text — which paints into a tiny vertical span and then jumps to a
|
||||||
|
// full-height table on resolve, the canonical CLS source — for a
|
||||||
|
// layout-shape-matching skeleton table sized to the actual column
|
||||||
|
// count. The eye reads "table loading here" and the eventual data
|
||||||
|
// lands in the same DOM rectangle with zero reflow.
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return <Skeleton variant="table" columns={columns.length + (selectable ? 1 : 0)} />;
|
||||||
<div className="flex items-center justify-center py-16 text-ink-muted">
|
|
||||||
<svg className="animate-spin h-5 w-5 mr-3" viewBox="0 0 24 24">
|
|
||||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" fill="none" />
|
|
||||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
|
|
||||||
</svg>
|
|
||||||
Loading...
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data.length) {
|
if (!data.length) {
|
||||||
|
// UX-M3 / Phase 1: prefer the rich <EmptyState /> slot when supplied;
|
||||||
|
// fall back to the legacy string render so existing call sites with
|
||||||
|
// emptyMessage="…" stay unchanged.
|
||||||
|
if (emptyState) {
|
||||||
|
return <>{emptyState}</>;
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-center py-16 text-ink-faint">
|
<div className="flex items-center justify-center py-16 text-ink-faint">
|
||||||
{emptyMessage || 'No data found'}
|
{emptyMessage || 'No data found'}
|
||||||
@@ -83,7 +98,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b-2 border-surface-border bg-surface-muted">
|
<tr className="border-b-2 border-surface-border bg-surface-muted">
|
||||||
{selectable && (
|
{selectable && (
|
||||||
<th className="px-3 py-3 w-10">
|
<th scope="col" className="px-3 py-3 w-10">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={allSelected || false}
|
checked={allSelected || false}
|
||||||
@@ -93,7 +108,7 @@ export default function DataTable<T>({ columns, data, onRowClick, emptyMessage,
|
|||||||
</th>
|
</th>
|
||||||
)}
|
)}
|
||||||
{columns.map(col => (
|
{columns.map(col => (
|
||||||
<th key={col.key} className={`px-4 py-3 text-left text-xs font-semibold text-ink-muted uppercase tracking-wider ${col.className || ''}`}>
|
<th key={col.key} scope="col" className={`px-4 py-3 text-left text-xs font-semibold text-ink-muted uppercase tracking-wider ${col.className || ''}`}>
|
||||||
{col.label}
|
{col.label}
|
||||||
</th>
|
</th>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -0,0 +1,78 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import EmptyState from './EmptyState';
|
||||||
|
|
||||||
|
describe('EmptyState', () => {
|
||||||
|
it('renders the title', () => {
|
||||||
|
render(<EmptyState title="No certificates yet" />);
|
||||||
|
expect(screen.getByText('No certificates yet')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders description when provided', () => {
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No certificates yet"
|
||||||
|
description="Issue your first certificate to get started."
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
screen.getByText('Issue your first certificate to get started.'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders icon slot when provided', () => {
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
icon={<span data-testid="empty-icon">📜</span>}
|
||||||
|
title="No certificates"
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId('empty-icon')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders primaryAction button and fires its onClick', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No certificates"
|
||||||
|
primaryAction={{ label: 'Issue certificate', onClick }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Issue certificate' }));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders secondaryAction button and fires its onClick', () => {
|
||||||
|
const onClick = vi.fn();
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No certificates"
|
||||||
|
secondaryAction={{ label: 'Read docs', onClick }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: 'Read docs' }));
|
||||||
|
expect(onClick).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders both actions side-by-side', () => {
|
||||||
|
render(
|
||||||
|
<EmptyState
|
||||||
|
title="No certificates"
|
||||||
|
primaryAction={{ label: 'Issue', onClick: () => {} }}
|
||||||
|
secondaryAction={{ label: 'Connect issuer', onClick: () => {} }}
|
||||||
|
/>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Issue' })).toBeInTheDocument();
|
||||||
|
expect(
|
||||||
|
screen.getByRole('button', { name: 'Connect issuer' }),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exposes role="status" for screen readers', () => {
|
||||||
|
render(<EmptyState title="No data" />);
|
||||||
|
expect(screen.getByRole('status')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// EmptyState — the certctl-themed empty-state primitive. Phase 1
|
||||||
|
// closure for UX-M3 (no <EmptyState> primitive; DataTable shows a bare
|
||||||
|
// 'No data found' string).
|
||||||
|
//
|
||||||
|
// Two render paths:
|
||||||
|
// 1) `<EmptyState title="..." description="..." />` — minimum
|
||||||
|
// acceptable empty state. Title is required (the user must
|
||||||
|
// understand what's missing); description + actions are optional.
|
||||||
|
// 2) `<EmptyState icon={<Icon />} title="..." description="..."
|
||||||
|
// primaryAction={{ label, onClick }} secondaryAction={...} />` —
|
||||||
|
// first-run CTA shape. Renders icon at the top, title in the
|
||||||
|
// middle, two action buttons at the bottom. Use this on list pages
|
||||||
|
// that an operator might hit on their first visit ("No certs yet —
|
||||||
|
// [Issue first certificate] [Connect an issuer]").
|
||||||
|
//
|
||||||
|
// Composition with DataTable: DataTable accepts `emptyState?: ReactNode`
|
||||||
|
// (added alongside the existing `emptyMessage?: string` for backward
|
||||||
|
// compat) so list pages can pass either a string or a full <EmptyState />
|
||||||
|
// component.
|
||||||
|
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
|
||||||
|
export interface EmptyStateAction {
|
||||||
|
label: string;
|
||||||
|
onClick: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface EmptyStateProps {
|
||||||
|
/** Optional icon at the top. Pass any ReactNode (lucide / SVG / emoji). */
|
||||||
|
icon?: ReactNode;
|
||||||
|
/** Required headline. Keep short: "No certificates yet". */
|
||||||
|
title: string;
|
||||||
|
/** Optional sub-copy. One sentence explaining the empty condition. */
|
||||||
|
description?: string;
|
||||||
|
/** Optional primary CTA. Renders as .btn-primary. */
|
||||||
|
primaryAction?: EmptyStateAction;
|
||||||
|
/** Optional secondary CTA. Renders as .btn-outline alongside primary. */
|
||||||
|
secondaryAction?: EmptyStateAction;
|
||||||
|
/** Override default centering / padding when nested inside a card. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EmptyState({
|
||||||
|
icon,
|
||||||
|
title,
|
||||||
|
description,
|
||||||
|
primaryAction,
|
||||||
|
secondaryAction,
|
||||||
|
className,
|
||||||
|
}: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
className={
|
||||||
|
className ||
|
||||||
|
'flex flex-col items-center justify-center text-center py-16 px-6'
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{icon && (
|
||||||
|
<div className="mb-4 text-ink-faint" aria-hidden="true">
|
||||||
|
{icon}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<h3 className="text-base font-semibold text-ink mb-1">{title}</h3>
|
||||||
|
{description && (
|
||||||
|
<p className="text-sm text-ink-muted max-w-md mb-4">{description}</p>
|
||||||
|
)}
|
||||||
|
{(primaryAction || secondaryAction) && (
|
||||||
|
<div className="flex items-center gap-2 mt-2">
|
||||||
|
{primaryAction && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={primaryAction.onClick}
|
||||||
|
>
|
||||||
|
{primaryAction.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{secondaryAction && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-outline"
|
||||||
|
onClick={secondaryAction.onClick}
|
||||||
|
>
|
||||||
|
{secondaryAction.label}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { useForm } from 'react-hook-form';
|
||||||
|
import FormField from './FormField';
|
||||||
|
|
||||||
|
describe('FormField', () => {
|
||||||
|
it('label htmlFor matches input id (the WCAG 1.3.1 contract)', () => {
|
||||||
|
render(
|
||||||
|
<FormField label="Email">
|
||||||
|
<input type="email" />
|
||||||
|
</FormField>,
|
||||||
|
);
|
||||||
|
const label = screen.getByText('Email');
|
||||||
|
const input = screen.getByLabelText('Email');
|
||||||
|
// Programmatic label association — what screen readers use.
|
||||||
|
expect(input).toBeInTheDocument();
|
||||||
|
expect(label).toHaveAttribute('for', input.id);
|
||||||
|
// useId() gives a non-empty id by definition.
|
||||||
|
expect(input.id).toMatch(/^field-/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('two siblings get independent ids (no collision)', () => {
|
||||||
|
render(
|
||||||
|
<>
|
||||||
|
<FormField label="Name"><input /></FormField>
|
||||||
|
<FormField label="Description"><input /></FormField>
|
||||||
|
</>,
|
||||||
|
);
|
||||||
|
const a = screen.getByLabelText('Name');
|
||||||
|
const b = screen.getByLabelText('Description');
|
||||||
|
expect(a.id).not.toBe(b.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('required surfaces the asterisk + aria-required on the child', () => {
|
||||||
|
render(
|
||||||
|
<FormField label="Email" required>
|
||||||
|
<input type="email" />
|
||||||
|
</FormField>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('*')).toBeInTheDocument();
|
||||||
|
expect(screen.getByLabelText(/Email/)).toHaveAttribute('aria-required', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('description wires aria-describedby to the child', () => {
|
||||||
|
render(
|
||||||
|
<FormField label="Token" description="Paste the API key from /auth/keys">
|
||||||
|
<input />
|
||||||
|
</FormField>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText('Token');
|
||||||
|
const desc = screen.getByText(/Paste the API key/);
|
||||||
|
expect(input.getAttribute('aria-describedby')).toContain(desc.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('error sets aria-invalid + role=alert + extends aria-describedby', () => {
|
||||||
|
render(
|
||||||
|
<FormField label="Email" error="Must be a valid email address">
|
||||||
|
<input type="email" />
|
||||||
|
</FormField>,
|
||||||
|
);
|
||||||
|
const input = screen.getByLabelText('Email');
|
||||||
|
expect(input).toHaveAttribute('aria-invalid', 'true');
|
||||||
|
const err = screen.getByRole('alert');
|
||||||
|
expect(err).toHaveTextContent('Must be a valid email address');
|
||||||
|
expect(input.getAttribute('aria-describedby')).toContain(err.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('composes cleanly with react-hook-form register() — spread + clone preserves both', () => {
|
||||||
|
function Form({ onSubmit }: { onSubmit: (v: { name: string }) => void }) {
|
||||||
|
const { register, handleSubmit } = useForm<{ name: string }>();
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<FormField label="Name">
|
||||||
|
<input {...register('name')} />
|
||||||
|
</FormField>
|
||||||
|
<button type="submit">Save</button>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let captured = '';
|
||||||
|
render(<Form onSubmit={(v) => { captured = v.name; }} />);
|
||||||
|
const input = screen.getByLabelText('Name');
|
||||||
|
fireEvent.change(input, { target: { value: 'alice' } });
|
||||||
|
fireEvent.click(screen.getByText('Save'));
|
||||||
|
return new Promise<void>((resolve) => {
|
||||||
|
setTimeout(() => {
|
||||||
|
expect(captured).toBe('alice');
|
||||||
|
// Both RHF's name and FormField's id co-exist.
|
||||||
|
expect(input.getAttribute('name')).toBe('name');
|
||||||
|
expect(input.id).toMatch(/^field-/);
|
||||||
|
resolve();
|
||||||
|
}, 10);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('throws clearly when child is not a single valid element', () => {
|
||||||
|
// Suppress React's error-boundary console spam for this assertion.
|
||||||
|
const orig = console.error;
|
||||||
|
console.error = () => {};
|
||||||
|
try {
|
||||||
|
expect(() =>
|
||||||
|
render(
|
||||||
|
<FormField label="Bad">
|
||||||
|
{'plain string is not valid'}
|
||||||
|
</FormField>,
|
||||||
|
),
|
||||||
|
).toThrow();
|
||||||
|
} finally {
|
||||||
|
console.error = orig;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// FormField — Phase 5 closure for UX-H4 + the foundation of FE-M1.
|
||||||
|
//
|
||||||
|
// Pre-Phase-5 state: 139 <label> elements in production tsx; 6 with
|
||||||
|
// htmlFor; 0 inputs with id. WCAG 1.3.1 (info-and-relationships) fails
|
||||||
|
// on ~99% of form fields — screen readers can't programmatically pair
|
||||||
|
// a label with its input, so "Email" reads as a floating string rather
|
||||||
|
// than as the accessible name of the adjacent input.
|
||||||
|
//
|
||||||
|
// FormField fixes this by generating a stable id with React 18's
|
||||||
|
// useId() and threading it to BOTH the <label htmlFor=...> AND the
|
||||||
|
// child input's id prop via cloneElement. Consumers write:
|
||||||
|
//
|
||||||
|
// <FormField label="Email" required>
|
||||||
|
// <input type="email" value={email} onChange={…} />
|
||||||
|
// </FormField>
|
||||||
|
//
|
||||||
|
// — no manual id wiring, no risk of id-mismatch drift, no chance a
|
||||||
|
// developer copies the JSX and forgets to update one of the two
|
||||||
|
// strings. The label-↔-input binding is correct by construction.
|
||||||
|
//
|
||||||
|
// Composition with react-hook-form is straight-forward — RHF's
|
||||||
|
// register('field') returns onChange/onBlur/ref/name which spread onto
|
||||||
|
// the input alongside FormField's auto-id. The Zod-resolver path picks
|
||||||
|
// up errors and FormField surfaces them via the `error` prop slot.
|
||||||
|
|
||||||
|
import { Children, cloneElement, isValidElement, useId } from 'react';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
|
||||||
|
interface FormFieldProps {
|
||||||
|
/** Visible label text. Required for a11y — never render an unbound input. */
|
||||||
|
label: string;
|
||||||
|
/** Render `*` next to the label when true (display-only; validation lives in Zod). */
|
||||||
|
required?: boolean;
|
||||||
|
/** Optional helper / description text below the input. */
|
||||||
|
description?: string;
|
||||||
|
/** Optional error message — when set, surfaces below the input + flags aria-invalid. */
|
||||||
|
error?: string;
|
||||||
|
/** Optional class override for the wrapping div. */
|
||||||
|
className?: string;
|
||||||
|
/**
|
||||||
|
* Exactly one input-shaped child (<input>, <select>, <textarea>, or any
|
||||||
|
* forwardRef'd component that accepts `id` + `aria-describedby` +
|
||||||
|
* `aria-invalid` as props). FormField clones it and injects the
|
||||||
|
* auto-generated id so the label-↔-input pairing is correct by
|
||||||
|
* construction.
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FormField({
|
||||||
|
label,
|
||||||
|
required,
|
||||||
|
description,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: FormFieldProps) {
|
||||||
|
// useId() returns a stable id that's unique per render-tree-position,
|
||||||
|
// safe under StrictMode, and SSR-friendly. Two siblings get different
|
||||||
|
// ids automatically.
|
||||||
|
const reactId = useId();
|
||||||
|
const inputId = `field-${reactId}`;
|
||||||
|
const descId = description ? `desc-${reactId}` : undefined;
|
||||||
|
const errorId = error ? `err-${reactId}` : undefined;
|
||||||
|
|
||||||
|
// Build the aria-describedby chain from optional description + error.
|
||||||
|
// Browsers concatenate space-separated ids, so screen readers announce
|
||||||
|
// "Email, [description], [error]".
|
||||||
|
const describedBy = [descId, errorId].filter(Boolean).join(' ') || undefined;
|
||||||
|
|
||||||
|
const onlyChild = Children.only(children);
|
||||||
|
if (!isValidElement(onlyChild)) {
|
||||||
|
// Surface a clear error in dev rather than render a broken control.
|
||||||
|
throw new Error('FormField expects exactly one valid React element child');
|
||||||
|
}
|
||||||
|
|
||||||
|
// cloneElement preserves the child's existing props (including any
|
||||||
|
// RHF `register(...)` spread) and overlays the FormField-managed
|
||||||
|
// a11y props on top. The child's `id` / `aria-*` are always set
|
||||||
|
// here, but `name`/`value`/`onChange` from the child are preserved.
|
||||||
|
const childWithA11y = cloneElement(
|
||||||
|
onlyChild as ReactElement<Record<string, unknown>>,
|
||||||
|
{
|
||||||
|
id: inputId,
|
||||||
|
'aria-describedby': describedBy,
|
||||||
|
'aria-invalid': error ? true : undefined,
|
||||||
|
'aria-required': required ? true : undefined,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className ?? 'mb-4'}>
|
||||||
|
<label
|
||||||
|
htmlFor={inputId}
|
||||||
|
className="block text-sm font-medium text-ink mb-1.5"
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
{required && (
|
||||||
|
<span className="text-red-600 ml-0.5" aria-hidden="true">*</span>
|
||||||
|
)}
|
||||||
|
</label>
|
||||||
|
{childWithA11y}
|
||||||
|
{description && (
|
||||||
|
<p id={descId} className="mt-1 text-xs text-ink-muted">
|
||||||
|
{description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<p id={errorId} role="alert" className="mt-1 text-xs text-red-700">
|
||||||
|
{error}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+276
-78
@@ -1,62 +1,202 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Phase 3 joint closure (UX-H1 + FE-H2 + FE-L4, 2026-05-14):
|
||||||
|
//
|
||||||
|
// UX-H1 — sidebar regrouped from a flat 31-item list into 7 semantic
|
||||||
|
// groups: Inventory, Trust, Delivery, People, Notify, Access, Audit.
|
||||||
|
// Audit-accuracy callout: the original UX-H1 finding's wording
|
||||||
|
// ("/auth/* completely absent from primary nav") was factually wrong
|
||||||
|
// — all 8 /auth/* entries + /audit were already in the array; the
|
||||||
|
// issue was UNGROUPED, not absent. The correct framing is "31 flat
|
||||||
|
// items, no hierarchy, scroll-list to find Audit Trail."
|
||||||
|
//
|
||||||
|
// FE-H2 — every nav item now carries a lucide-react icon component
|
||||||
|
// reference instead of a literal SVG path string. 31 path strings
|
||||||
|
// removed; 27 named lucide imports added.
|
||||||
|
//
|
||||||
|
// FE-L4 — collapsible groups (click the group header to fold/unfold)
|
||||||
|
// give the keyboard-first power-user a way to compact the sidebar
|
||||||
|
// to just the surfaces they care about. State persists per-group in
|
||||||
|
// localStorage so the choice survives reloads.
|
||||||
|
//
|
||||||
|
// FE-M6 (CSP unsafe-inline tightening) is NOT closed here — pre-Phase-3
|
||||||
|
// re-verification confirmed the CSP comment on style-src 'unsafe-inline'
|
||||||
|
// cites "Tailwind (via Vite) injects per-component <style> blocks at
|
||||||
|
// build time," not inline SVG attributes. There are also 17 production
|
||||||
|
// tsx files with React style={...} attributes (Tooltip, AgentFleetPage,
|
||||||
|
// UsersPage, etc.) that emit inline styles. Tightening the CSP needs
|
||||||
|
// all those paths migrated to utility classes/CSS variables — out of
|
||||||
|
// scope for this phase.
|
||||||
|
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
|
||||||
|
import {
|
||||||
|
// Inventory
|
||||||
|
LayoutDashboard, ShieldCheck, Search, Server, Network, Radar, Timer,
|
||||||
|
// Trust
|
||||||
|
KeyRound, FileText, ScrollText, RefreshCw, Wrench,
|
||||||
|
// Delivery
|
||||||
|
Target, ListTodo, HeartPulse,
|
||||||
|
// People
|
||||||
|
User, Users, Group,
|
||||||
|
// Notify
|
||||||
|
Bell, Inbox, Activity,
|
||||||
|
// Access
|
||||||
|
Clock, UserCog, CheckCircle2, AlertTriangle, Cog,
|
||||||
|
// Logout + setup
|
||||||
|
LogOut, HelpCircle,
|
||||||
|
// Group header chevron
|
||||||
|
ChevronDown, ChevronRight,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
import { useAuth } from './AuthProvider';
|
import { useAuth } from './AuthProvider';
|
||||||
|
import { ExternalLink } from './ExternalLink';
|
||||||
import logo from '../assets/certctl-logo.png';
|
import logo from '../assets/certctl-logo.png';
|
||||||
|
|
||||||
const nav = [
|
// -----------------------------------------------------------------------------
|
||||||
{ to: '/', label: 'Dashboard', icon: 'M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-4 0h4' },
|
// Nav model — 7 semantic groups across 31 items.
|
||||||
{ to: '/certificates', label: 'Certificates', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
|
// -----------------------------------------------------------------------------
|
||||||
{ to: '/agents', label: 'Agents', icon: 'M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2' },
|
interface NavItem {
|
||||||
{ to: '/fleet', label: 'Fleet Overview', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z' },
|
to: string;
|
||||||
{ to: '/jobs', label: 'Jobs', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
label: string;
|
||||||
{ to: '/notifications', label: 'Notifications', icon: 'M15 17h5l-1.405-1.405A2.032 2.032 0 0118 14.158V11a6.002 6.002 0 00-4-5.659V5a2 2 0 10-4 0v.341C7.67 6.165 6 8.388 6 11v3.159c0 .538-.214 1.055-.595 1.436L4 17h5m6 0v1a3 3 0 11-6 0v-1m6 0H9' },
|
icon: LucideIcon;
|
||||||
{ to: '/policies', label: 'Policies', icon: 'M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4' },
|
/** Optional data-testid; today only `nav-auth-users` (Audit 2026-05-11 Fix 11). */
|
||||||
{ to: '/renewal-policies', label: 'Renewal Policies', icon: 'M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15' },
|
testID?: string;
|
||||||
{ to: '/profiles', label: 'Profiles', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
}
|
||||||
{ to: '/issuers', label: 'Issuers', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
interface NavGroup {
|
||||||
{ to: '/targets', label: 'Targets', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10' },
|
/** localStorage key suffix for collapsed-state persistence. */
|
||||||
{ to: '/owners', label: 'Owners', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
|
id: string;
|
||||||
{ to: '/teams', label: 'Teams', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z' },
|
/** Sidebar header label. */
|
||||||
{ to: '/agent-groups', label: 'Agent Groups', icon: 'M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10 M9 3v2m6-2v2' },
|
label: string;
|
||||||
{ to: '/discovery', label: 'Discovery', icon: 'M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z' },
|
items: NavItem[];
|
||||||
{ to: '/network-scans', label: 'Network Scans', icon: 'M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z M9 12l2 2 4-4' },
|
|
||||||
{ to: '/health-monitor', label: 'Health Monitor', icon: 'M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z' },
|
|
||||||
{ to: '/short-lived', label: 'Short-Lived', icon: 'M13 10V3L4 14h7v7l9-11h-7z' },
|
|
||||||
{ to: '/digest', label: 'Digest', icon: 'M3 8l7.89 5.26a2 2 0 002.22 0L21 8M5 19h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z' },
|
|
||||||
{ to: '/observability', label: 'Observability', icon: 'M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z' },
|
|
||||||
{ to: '/scep', label: 'SCEP Admin', icon: 'M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z' },
|
|
||||||
{ to: '/est', label: 'EST Admin', icon: 'M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z' },
|
|
||||||
{ to: '/audit', label: 'Audit Trail', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
|
||||||
// Bundle 1 Phase 10 — RBAC management (Roles / Keys / Settings).
|
|
||||||
// Bundle 2 Phase 8 — OIDC + Sessions.
|
|
||||||
{ to: '/auth/oidc/providers', label: 'OIDC Providers', icon: 'M12 11c0 3.517-1.009 6.799-2.753 9.571m-3.44-2.04l.054-.09A13.916 13.916 0 008 11a4 4 0 118 0c0 1.017-.07 2.019-.203 3m-2.118 6.844A21.88 21.88 0 0015.171 17m3.839 1.132c.645-2.266.99-4.659.99-7.132A8 8 0 008 4.07M3 15.364c.64-1.319 1-2.8 1-4.364 0-1.457.39-2.823 1.07-4' },
|
|
||||||
{ to: '/auth/sessions', label: 'Sessions', icon: 'M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z' },
|
|
||||||
// Audit 2026-05-11 Fix 11 — UsersPage sidebar entry (MED-11 discoverability).
|
|
||||||
// The MED-11 closure wired UsersPage but no nav entry; operators had to know
|
|
||||||
// the URL /auth/users to reach the federated-user-management surface. This
|
|
||||||
// entry sits adjacent to Sessions because the two share the same mental
|
|
||||||
// model (federated identity admin). UsersPage handles its own 403 state for
|
|
||||||
// callers without auth.user.read so we don't need to gate the nav entry;
|
|
||||||
// every other entry in this array uses the same unconditional pattern.
|
|
||||||
{ to: '/auth/users', label: 'Users', icon: 'M17 20h5v-2a3 3 0 00-5.356-1.857M17 20H7m10 0v-2c0-.656-.126-1.283-.356-1.857M7 20H2v-2a3 3 0 015.356-1.857M7 20v-2c0-.656.126-1.283.356-1.857m0 0a5.002 5.002 0 019.288 0M15 7a3 3 0 11-6 0 3 3 0 016 0zm6 3a2 2 0 11-4 0 2 2 0 014 0zM7 10a2 2 0 11-4 0 2 2 0 014 0z', testID: 'nav-auth-users' },
|
|
||||||
{ to: '/auth/roles', label: 'Roles', icon: 'M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z' },
|
|
||||||
{ to: '/auth/keys', label: 'API Keys', icon: 'M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z' },
|
|
||||||
{ to: '/auth/approvals', label: 'Approvals', icon: 'M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z' },
|
|
||||||
// Audit 2026-05-10 CRIT-4 closure — break-glass admin surface.
|
|
||||||
{ to: '/auth/breakglass', label: 'Break-glass', icon: 'M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z' },
|
|
||||||
{ to: '/auth/settings', label: 'Auth Settings', icon: 'M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z M15 12a3 3 0 11-6 0 3 3 0 016 0z' },
|
|
||||||
];
|
|
||||||
|
|
||||||
function Icon({ d }: { d: string }) {
|
|
||||||
return (
|
|
||||||
<svg className="w-[18px] h-[18px] shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d={d} />
|
|
||||||
</svg>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const navGroups: NavGroup[] = [
|
||||||
|
{
|
||||||
|
id: 'inventory',
|
||||||
|
label: 'Inventory',
|
||||||
|
items: [
|
||||||
|
{ to: '/', label: 'Dashboard', icon: LayoutDashboard },
|
||||||
|
{ to: '/certificates', label: 'Certificates', icon: ShieldCheck },
|
||||||
|
{ to: '/discovery', label: 'Discovery', icon: Search },
|
||||||
|
{ to: '/agents', label: 'Agents', icon: Server },
|
||||||
|
{ to: '/fleet', label: 'Fleet Overview', icon: Network },
|
||||||
|
{ to: '/network-scans', label: 'Network Scans', icon: Radar },
|
||||||
|
{ to: '/short-lived', label: 'Short-Lived', icon: Timer },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'trust',
|
||||||
|
label: 'Trust',
|
||||||
|
items: [
|
||||||
|
{ to: '/issuers', label: 'Issuers', icon: KeyRound },
|
||||||
|
{ to: '/profiles', label: 'Profiles', icon: FileText },
|
||||||
|
{ to: '/policies', label: 'Policies', icon: ScrollText },
|
||||||
|
{ to: '/renewal-policies', label: 'Renewal Policies', icon: RefreshCw },
|
||||||
|
{ to: '/scep', label: 'SCEP Admin', icon: Wrench },
|
||||||
|
{ to: '/est', label: 'EST Admin', icon: Wrench },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'delivery',
|
||||||
|
label: 'Delivery',
|
||||||
|
items: [
|
||||||
|
{ to: '/targets', label: 'Targets', icon: Target },
|
||||||
|
{ to: '/jobs', label: 'Jobs', icon: ListTodo },
|
||||||
|
{ to: '/health-monitor', label: 'Health Monitor', icon: HeartPulse },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'people',
|
||||||
|
label: 'People',
|
||||||
|
items: [
|
||||||
|
{ to: '/owners', label: 'Owners', icon: User },
|
||||||
|
{ to: '/teams', label: 'Teams', icon: Users },
|
||||||
|
{ to: '/agent-groups', label: 'Agent Groups', icon: Group },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'notify',
|
||||||
|
label: 'Notify',
|
||||||
|
items: [
|
||||||
|
{ to: '/notifications', label: 'Notifications', icon: Bell },
|
||||||
|
{ to: '/digest', label: 'Digest', icon: Inbox },
|
||||||
|
{ to: '/observability', label: 'Observability', icon: Activity },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'access',
|
||||||
|
label: 'Access',
|
||||||
|
items: [
|
||||||
|
// Bundle 2 Phase 8 — OIDC + Sessions.
|
||||||
|
{ to: '/auth/oidc/providers', label: 'OIDC Providers', icon: ShieldCheck },
|
||||||
|
{ to: '/auth/sessions', label: 'Sessions', icon: Clock },
|
||||||
|
// Audit 2026-05-11 Fix 11 — `nav-auth-users` testid pins this entry's
|
||||||
|
// selectability; sit Users immediately after Sessions to preserve the
|
||||||
|
// federated-identity DOM order asserted in Layout.test.tsx.
|
||||||
|
{ to: '/auth/users', label: 'Users', icon: Users, testID: 'nav-auth-users' },
|
||||||
|
{ to: '/auth/roles', label: 'Roles', icon: UserCog },
|
||||||
|
{ to: '/auth/keys', label: 'API Keys', icon: KeyRound },
|
||||||
|
{ to: '/auth/approvals', label: 'Approvals', icon: CheckCircle2 },
|
||||||
|
// Audit 2026-05-10 CRIT-4 closure — break-glass admin.
|
||||||
|
{ to: '/auth/breakglass', label: 'Break-glass', icon: AlertTriangle },
|
||||||
|
{ to: '/auth/settings', label: 'Auth Settings', icon: Cog },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'audit',
|
||||||
|
label: 'Audit',
|
||||||
|
items: [
|
||||||
|
{ to: '/audit', label: 'Audit Trail', icon: ScrollText },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// useCollapsedGroups — persist per-group collapsed state in localStorage.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
const STORAGE_KEY = 'certctl:nav:collapsed-groups';
|
||||||
|
|
||||||
|
function useCollapsedGroups(): [Set<string>, (id: string) => void] {
|
||||||
|
const [collapsed, setCollapsed] = useState<Set<string>>(() => {
|
||||||
|
if (typeof window === 'undefined') return new Set();
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY);
|
||||||
|
return new Set(raw ? (JSON.parse(raw) as string[]) : []);
|
||||||
|
} catch {
|
||||||
|
return new Set();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify([...collapsed]));
|
||||||
|
} catch {
|
||||||
|
/* noop — storage quota / privacy mode */
|
||||||
|
}
|
||||||
|
}, [collapsed]);
|
||||||
|
|
||||||
|
const toggle = (id: string) => {
|
||||||
|
setCollapsed((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (next.has(id)) next.delete(id);
|
||||||
|
else next.add(id);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return [collapsed, toggle];
|
||||||
|
}
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// Layout
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
export default function Layout() {
|
export default function Layout() {
|
||||||
const { authRequired, logout } = useAuth();
|
const { authRequired, logout } = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const [collapsed, toggleGroup] = useCollapsedGroups();
|
||||||
|
|
||||||
const openSetupGuide = () => {
|
const openSetupGuide = () => {
|
||||||
try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ }
|
try { localStorage.removeItem('certctl:onboarding-dismissed'); } catch { /* noop */ }
|
||||||
@@ -70,33 +210,66 @@ export default function Layout() {
|
|||||||
{/* Logo — large and prominent */}
|
{/* Logo — large and prominent */}
|
||||||
<div className="px-4 pt-5 pb-4 flex flex-col items-center gap-2">
|
<div className="px-4 pt-5 pb-4 flex flex-col items-center gap-2">
|
||||||
<div className="bg-white rounded-xl p-2 shadow-lg">
|
<div className="bg-white rounded-xl p-2 shadow-lg">
|
||||||
<img src={logo} alt="certctl" className="h-16 w-16" />
|
<img src={logo} alt="certctl" className="h-16 w-16" width={64} height={64} loading="eager" decoding="async" />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center">
|
<div className="text-center">
|
||||||
<h1 className="text-lg font-bold text-white tracking-tight">certctl</h1>
|
<h1 className="text-lg font-bold text-white tracking-tight">certctl</h1>
|
||||||
<p className="text-[10px] text-brand-300 uppercase tracking-[0.2em]">Control Plane</p>
|
<p className="text-2xs text-brand-300 uppercase tracking-[0.2em]">Control Plane</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 py-2 px-3 space-y-0.5 overflow-y-auto">
|
<nav className="flex-1 py-2 px-3 space-y-3 overflow-y-auto" aria-label="Primary navigation">
|
||||||
{nav.map(item => (
|
{navGroups.map((group) => {
|
||||||
<NavLink
|
const isCollapsed = collapsed.has(group.id);
|
||||||
key={item.to}
|
return (
|
||||||
to={item.to}
|
<div key={group.id} className="space-y-0.5">
|
||||||
end={item.to === '/'}
|
{/* Group header — clickable to toggle collapse. */}
|
||||||
data-testid={'testID' in item ? item.testID : undefined}
|
<button
|
||||||
className={({ isActive }) =>
|
type="button"
|
||||||
`flex items-center gap-3 px-3 py-2 text-[13px] rounded transition-all duration-150 ${
|
onClick={() => toggleGroup(group.id)}
|
||||||
isActive
|
aria-expanded={!isCollapsed}
|
||||||
? 'bg-white/15 text-white font-semibold shadow-sm'
|
aria-controls={`nav-group-${group.id}`}
|
||||||
: 'text-sidebar-text hover:text-white hover:bg-white/10'
|
className="w-full flex items-center justify-between px-3 py-1.5 text-2xs uppercase tracking-wider text-brand-300/60 hover:text-brand-300 transition-colors border-t border-white/10 pt-2 mt-1 first:border-t-0 first:pt-1 first:mt-0"
|
||||||
}`
|
>
|
||||||
}
|
<span>{group.label}</span>
|
||||||
>
|
{isCollapsed
|
||||||
<Icon d={item.icon} />
|
? <ChevronRight className="w-3 h-3 shrink-0" aria-hidden="true" />
|
||||||
{item.label}
|
: <ChevronDown className="w-3 h-3 shrink-0" aria-hidden="true" />}
|
||||||
</NavLink>
|
</button>
|
||||||
))}
|
{/* Group items — fold via inline display:none when collapsed
|
||||||
|
(vs unmount) so the NavLinks retain focus state and the
|
||||||
|
operator's next click doesn't re-render the entire group.
|
||||||
|
aria-hidden mirrors the visual state for screen readers. */}
|
||||||
|
<div
|
||||||
|
id={`nav-group-${group.id}`}
|
||||||
|
className={`space-y-0.5 ${isCollapsed ? 'hidden' : ''}`}
|
||||||
|
aria-hidden={isCollapsed}
|
||||||
|
>
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const ItemIcon = item.icon;
|
||||||
|
return (
|
||||||
|
<NavLink
|
||||||
|
key={item.to}
|
||||||
|
to={item.to}
|
||||||
|
end={item.to === '/'}
|
||||||
|
data-testid={item.testID}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center gap-3 px-3 py-2 text-sm rounded transition-all duration-150 ${
|
||||||
|
isActive
|
||||||
|
? 'bg-white/15 text-white font-semibold shadow-sm'
|
||||||
|
: 'text-sidebar-text hover:text-white hover:bg-white/10'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<ItemIcon className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||||
|
{item.label}
|
||||||
|
</NavLink>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="px-3 pb-2 pt-2 border-t border-white/10">
|
<div className="px-3 pb-2 pt-2 border-t border-white/10">
|
||||||
@@ -104,24 +277,49 @@ export default function Layout() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={openSetupGuide}
|
onClick={openSetupGuide}
|
||||||
title="Reopen the onboarding wizard"
|
title="Reopen the onboarding wizard"
|
||||||
className="w-full flex items-center gap-3 px-3 py-2 text-[13px] rounded text-sidebar-text hover:text-white hover:bg-white/10 transition-all duration-150"
|
className="w-full flex items-center gap-3 px-3 py-2 text-sm rounded text-sidebar-text hover:text-white hover:bg-white/10 transition-all duration-150"
|
||||||
>
|
>
|
||||||
<Icon d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
<HelpCircle className="w-[18px] h-[18px] shrink-0" strokeWidth={1.75} aria-hidden="true" />
|
||||||
Setup guide
|
Setup guide
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="px-5 py-3 border-t border-white/10 flex items-center justify-between">
|
{/* Maintainer attribution row — mirrors the landing-page footer
|
||||||
<span className="text-[10px] text-brand-300/60 font-mono">certctl</span>
|
(certctl.io: "Built and maintained by Shankar · certctl.io").
|
||||||
|
Same font-mono / muted-text typography; only "Shankar" carries
|
||||||
|
the LinkedIn link (the same href + rel="me noopener" pattern
|
||||||
|
the landing page uses). Single-maintainer OSS standard
|
||||||
|
(Cal.com, Plausible, Beekeeper Studio do the same). */}
|
||||||
|
{/* Maintainer attribution row. The Bundle-8 L-015 CI guard line-greps
|
||||||
|
for `target="_blank"` without `rel="noopener noreferrer"` on the
|
||||||
|
SAME LINE — splitting target + rel across lines (as the prior
|
||||||
|
bare <a> did) tripped the guard. ExternalLink is the canonical
|
||||||
|
chokepoint that the guard allowlists. We lose the rel="me" hint
|
||||||
|
(LinkedIn's identity-claim signal, not load-bearing), but gain
|
||||||
|
the CI gate. */}
|
||||||
|
<div className="px-5 pt-3 pb-1 border-t border-white/10">
|
||||||
|
<span className="text-2xs text-sidebar-text/70 font-mono">
|
||||||
|
Built and maintained by{' '}
|
||||||
|
<ExternalLink
|
||||||
|
href="https://www.linkedin.com/in/shankar-k-a1b6853ba"
|
||||||
|
className="text-sidebar-text/90 hover:text-white transition-colors underline-offset-2 hover:underline"
|
||||||
|
title="Shankar on LinkedIn — opens in a new tab"
|
||||||
|
>
|
||||||
|
Shankar
|
||||||
|
</ExternalLink>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-5 pt-1 pb-3 flex items-center justify-between">
|
||||||
|
<span className="text-2xs text-brand-300/60 font-mono">certctl</span>
|
||||||
{authRequired && (
|
{authRequired && (
|
||||||
<button
|
<button
|
||||||
onClick={logout}
|
onClick={logout}
|
||||||
className="text-xs text-sidebar-text hover:text-white transition-colors"
|
className="text-xs text-sidebar-text hover:text-white transition-colors"
|
||||||
title="Sign out"
|
title="Sign out"
|
||||||
|
aria-label="Sign out"
|
||||||
>
|
>
|
||||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={1.5}>
|
<LogOut className="w-4 h-4" strokeWidth={1.75} aria-hidden="true" />
|
||||||
<path strokeLinecap="round" strokeLinejoin="round" d="M15.75 9V5.25A2.25 2.25 0 0013.5 3h-6a2.25 2.25 0 00-2.25 2.25v13.5A2.25 2.25 0 007.5 21h6a2.25 2.25 0 002.25-2.25V15m3 0l3-3m0 0l-3-3m3 3H9" />
|
|
||||||
</svg>
|
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import ModalDialog from './ModalDialog';
|
||||||
|
|
||||||
|
describe('ModalDialog', () => {
|
||||||
|
it('renders nothing when open=false', () => {
|
||||||
|
render(
|
||||||
|
<ModalDialog open={false} title="Hidden" onClose={() => {}}>
|
||||||
|
body content
|
||||||
|
</ModalDialog>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText('Hidden')).toBeNull();
|
||||||
|
expect(screen.queryByText('body content')).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders title + children when open', () => {
|
||||||
|
render(
|
||||||
|
<ModalDialog open={true} title="Confirm thing" onClose={() => {}}>
|
||||||
|
<p>This is the body</p>
|
||||||
|
</ModalDialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByText('Confirm thing')).toBeInTheDocument();
|
||||||
|
expect(screen.getByText('This is the body')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Headless UI sets role=dialog + aria-modal on the panel', () => {
|
||||||
|
render(
|
||||||
|
<ModalDialog open={true} title="t" onClose={() => {}}>
|
||||||
|
<span>body</span>
|
||||||
|
</ModalDialog>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
expect(dialog).toHaveAttribute('aria-modal', 'true');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('title acts as aria-labelledby target', () => {
|
||||||
|
render(
|
||||||
|
<ModalDialog open={true} title="Pin me" onClose={() => {}}>
|
||||||
|
<span>body</span>
|
||||||
|
</ModalDialog>,
|
||||||
|
);
|
||||||
|
const dialog = screen.getByRole('dialog');
|
||||||
|
const labelId = dialog.getAttribute('aria-labelledby');
|
||||||
|
expect(labelId).toBeTruthy();
|
||||||
|
const labelEl = document.getElementById(labelId!);
|
||||||
|
expect(labelEl).toHaveTextContent('Pin me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ESC key fires onClose', () => {
|
||||||
|
const onClose = vi.fn();
|
||||||
|
render(
|
||||||
|
<ModalDialog open={true} title="x" onClose={onClose}>
|
||||||
|
<span>body</span>
|
||||||
|
</ModalDialog>,
|
||||||
|
);
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(onClose).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('footer renders separately when provided', () => {
|
||||||
|
render(
|
||||||
|
<ModalDialog
|
||||||
|
open={true}
|
||||||
|
title="x"
|
||||||
|
onClose={() => {}}
|
||||||
|
footer={<button>OK</button>}
|
||||||
|
>
|
||||||
|
body
|
||||||
|
</ModalDialog>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'OK' })).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// ModalDialog — Phase 5 closure for FE-H3 (3 inline-managed modal
|
||||||
|
// pages — SCEPAdminPage, AgentsPage, ESTAdminPage — set
|
||||||
|
// role="dialog" + aria-modal="true" + aria-labelledby but no focus
|
||||||
|
// trap, no ESC-to-close, no backdrop-click-to-close).
|
||||||
|
//
|
||||||
|
// Built on Headless UI's <Dialog>, identical pattern to ConfirmDialog
|
||||||
|
// (Phase 1) but accepts arbitrary <ModalDialog.Body> content rather
|
||||||
|
// than the constrained confirm/cancel button pair ConfirmDialog
|
||||||
|
// provides. Use ConfirmDialog for "click YES to do destructive thing";
|
||||||
|
// use ModalDialog for "modal that contains a form / multi-action
|
||||||
|
// content / a status display".
|
||||||
|
//
|
||||||
|
// What Headless UI gives us for free (same as ConfirmDialog):
|
||||||
|
// • automatic focus trap (Tab/Shift-Tab stays inside the dialog)
|
||||||
|
// • automatic ESC-to-close → onClose() callback
|
||||||
|
// • automatic backdrop-click-to-close → onClose() callback
|
||||||
|
// • role="dialog" + aria-modal="true" on the panel
|
||||||
|
// • aria-labelledby on the title node
|
||||||
|
// • <Transition> respects prefers-reduced-motion via the global
|
||||||
|
// @media block in src/index.css
|
||||||
|
//
|
||||||
|
// FE-H3 closure scope: the 3 inline-managed modal sites all get
|
||||||
|
// migrated to this primitive in the same commit. ConfirmDialog stays
|
||||||
|
// as-is for confirm-only flows it already serves.
|
||||||
|
|
||||||
|
import { Fragment } from 'react';
|
||||||
|
import type { ReactNode } from 'react';
|
||||||
|
import { Dialog, Transition } from '@headlessui/react';
|
||||||
|
|
||||||
|
export interface ModalDialogProps {
|
||||||
|
/** Controls visibility. Parent owns the boolean. */
|
||||||
|
open: boolean;
|
||||||
|
/** Title shown at the top — also acts as aria-labelledby target. */
|
||||||
|
title: string;
|
||||||
|
/** Fires on ESC, backdrop click, or external close trigger. */
|
||||||
|
onClose: () => void;
|
||||||
|
/**
|
||||||
|
* Dialog body — render the form, status, or multi-action content here.
|
||||||
|
* The body is wrapped in the styled panel; consumers don't need to
|
||||||
|
* wrap their content in another <div>.
|
||||||
|
*/
|
||||||
|
children: ReactNode;
|
||||||
|
/**
|
||||||
|
* Footer slot for action buttons. Optional — some modals (e.g. error
|
||||||
|
* displays) only show a "Close" affordance which can live inside
|
||||||
|
* children. When provided, footer is separated by a top border.
|
||||||
|
*/
|
||||||
|
footer?: ReactNode;
|
||||||
|
/** Maximum width — defaults to `max-w-md` (matches ConfirmDialog). */
|
||||||
|
maxWidth?: 'sm' | 'md' | 'lg' | 'xl' | '2xl';
|
||||||
|
}
|
||||||
|
|
||||||
|
const maxWidthMap = {
|
||||||
|
sm: 'max-w-sm',
|
||||||
|
md: 'max-w-md',
|
||||||
|
lg: 'max-w-lg',
|
||||||
|
xl: 'max-w-xl',
|
||||||
|
'2xl': 'max-w-2xl',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
export default function ModalDialog({
|
||||||
|
open,
|
||||||
|
title,
|
||||||
|
onClose,
|
||||||
|
children,
|
||||||
|
footer,
|
||||||
|
maxWidth = 'md',
|
||||||
|
}: ModalDialogProps) {
|
||||||
|
return (
|
||||||
|
<Transition show={open} as={Fragment}>
|
||||||
|
<Dialog onClose={onClose} className="relative z-50">
|
||||||
|
{/* Backdrop. Headless UI wires backdrop-click → onClose. */}
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0"
|
||||||
|
enterTo="opacity-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100"
|
||||||
|
leaveTo="opacity-0"
|
||||||
|
>
|
||||||
|
<div className="fixed inset-0 bg-black/40" aria-hidden="true" />
|
||||||
|
</Transition.Child>
|
||||||
|
|
||||||
|
{/* Panel container. */}
|
||||||
|
<div className="fixed inset-0 flex items-center justify-center p-4">
|
||||||
|
<Transition.Child
|
||||||
|
as={Fragment}
|
||||||
|
enter="ease-out duration-200"
|
||||||
|
enterFrom="opacity-0 scale-95"
|
||||||
|
enterTo="opacity-100 scale-100"
|
||||||
|
leave="ease-in duration-150"
|
||||||
|
leaveFrom="opacity-100 scale-100"
|
||||||
|
leaveTo="opacity-0 scale-95"
|
||||||
|
>
|
||||||
|
<Dialog.Panel
|
||||||
|
className={`bg-surface w-full ${maxWidthMap[maxWidth]} rounded-lg shadow-xl border border-surface-border`}
|
||||||
|
>
|
||||||
|
<div className="p-6">
|
||||||
|
<Dialog.Title className="text-base font-semibold text-ink mb-3">
|
||||||
|
{title}
|
||||||
|
</Dialog.Title>
|
||||||
|
<div className="text-sm text-ink">{children}</div>
|
||||||
|
</div>
|
||||||
|
{footer && (
|
||||||
|
<div className="border-t border-surface-border px-6 py-4 flex justify-end gap-2">
|
||||||
|
{footer}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Dialog.Panel>
|
||||||
|
</Transition.Child>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</Transition>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import Breadcrumbs from './Breadcrumbs';
|
||||||
|
|
||||||
interface PageHeaderProps {
|
interface PageHeaderProps {
|
||||||
title: string;
|
title: string;
|
||||||
subtitle?: string;
|
subtitle?: string;
|
||||||
@@ -8,6 +10,14 @@ export default function PageHeader({ title, subtitle, action }: PageHeaderProps)
|
|||||||
return (
|
return (
|
||||||
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
|
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
|
||||||
<div>
|
<div>
|
||||||
|
{/* Phase 3 UX-M5 closure: breadcrumb trail derived from
|
||||||
|
useLocation() + the static pathSegmentLabels map in
|
||||||
|
Breadcrumbs.tsx (see that file's header comment for why
|
||||||
|
we pivoted away from the useMatches() + handle.crumb
|
||||||
|
pattern the audit prompt suggested). Renders nothing on
|
||||||
|
the dashboard root — backward-compatible with every
|
||||||
|
existing PageHeader consumer. */}
|
||||||
|
<Breadcrumbs />
|
||||||
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
<h2 className="text-lg font-semibold text-ink">{title}</h2>
|
||||||
{subtitle && <p className="text-sm text-ink-muted mt-0.5">{subtitle}</p>}
|
{subtitle && <p className="text-sm text-ink-muted mt-0.5">{subtitle}</p>}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { render } from '@testing-library/react';
|
||||||
|
import Skeleton from './Skeleton';
|
||||||
|
|
||||||
|
describe('Skeleton', () => {
|
||||||
|
it('page variant renders PageHeader-shaped band + 4 stat tiles + card', () => {
|
||||||
|
const { container, getByRole } = render(<Skeleton variant="page" />);
|
||||||
|
expect(getByRole('status')).toHaveAttribute('aria-busy', 'true');
|
||||||
|
expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading content');
|
||||||
|
expect(container.querySelector('.animate-pulse')).not.toBeNull();
|
||||||
|
// 4 stat tiles
|
||||||
|
expect(container.querySelectorAll('.grid > .bg-surface')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table variant defaults to 6 rows × 5 cols', () => {
|
||||||
|
const { container } = render(<Skeleton variant="table" />);
|
||||||
|
const rows = container.querySelectorAll('tbody tr');
|
||||||
|
expect(rows).toHaveLength(6);
|
||||||
|
const cells = rows[0].querySelectorAll('td');
|
||||||
|
expect(cells).toHaveLength(5);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('table variant respects custom rows + columns', () => {
|
||||||
|
const { container } = render(<Skeleton variant="table" rows={3} columns={4} />);
|
||||||
|
expect(container.querySelectorAll('tbody tr')).toHaveLength(3);
|
||||||
|
expect(container.querySelectorAll('tbody tr:first-child td')).toHaveLength(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('card variant renders title-row + 3 prose rows', () => {
|
||||||
|
const { container } = render(<Skeleton variant="card" />);
|
||||||
|
// 1 title + 3 prose lines = 4 stripes inside the inner card
|
||||||
|
const stripes = container.querySelectorAll('.bg-surface > div, .bg-surface .space-y-2 > div');
|
||||||
|
expect(stripes.length).toBeGreaterThanOrEqual(4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stat variant renders label-row + number-row', () => {
|
||||||
|
const { container, getByRole } = render(<Skeleton variant="stat" />);
|
||||||
|
expect(getByRole('status')).toHaveAttribute('aria-busy', 'true');
|
||||||
|
// 2 stripes
|
||||||
|
expect(container.querySelectorAll('.bg-surface-border')).toHaveLength(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('custom ariaLabel surfaces on the role=status root', () => {
|
||||||
|
const { getByRole } = render(
|
||||||
|
<Skeleton variant="card" ariaLabel="Loading certificates" />,
|
||||||
|
);
|
||||||
|
expect(getByRole('status')).toHaveAttribute('aria-label', 'Loading certificates');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Skeleton — Phase 4 closure for UX-M1 (206 isLoading sites render as
|
||||||
|
// "Loading…" text in PageHeader subtitle → layout shift on every fetch).
|
||||||
|
//
|
||||||
|
// Four variants, each shaped to match the page region it stands in for
|
||||||
|
// so the eventual content lands without CLS:
|
||||||
|
//
|
||||||
|
// • page — full-page Suspense fallback used by main.tsx route
|
||||||
|
// lazy-load boundaries. Includes a PageHeader-shaped
|
||||||
|
// skeleton + a body grid of card / table skeletons.
|
||||||
|
// • table — list-page body. 6 rows × 5 cells, header row dimmed.
|
||||||
|
// Drop into DataTable's isLoading branch (or page-local
|
||||||
|
// tables that don't go through DataTable yet).
|
||||||
|
// • card — single content card. One title-row + 3 prose rows.
|
||||||
|
// Composable inside dashboards / detail pages.
|
||||||
|
// • stat — KPI tile. One label-row + one large number-row.
|
||||||
|
// Sized to match DashboardPage's stat panels.
|
||||||
|
//
|
||||||
|
// Every variant uses Tailwind's `animate-pulse` on layout-shaped divs
|
||||||
|
// so the eye reads "content loading here" instead of a flash of empty
|
||||||
|
// container followed by re-flow when the real content paints.
|
||||||
|
//
|
||||||
|
// Accessibility: each variant carries role="status" + aria-busy="true"
|
||||||
|
// + aria-label so screen-reader users hear "Loading <region>" instead
|
||||||
|
// of an empty announcement.
|
||||||
|
|
||||||
|
interface SkeletonProps {
|
||||||
|
variant: 'page' | 'table' | 'card' | 'stat';
|
||||||
|
/** Override default aria-label. Default: "Loading content". */
|
||||||
|
ariaLabel?: string;
|
||||||
|
/** Number of rows for the `table` variant. Default 6. */
|
||||||
|
rows?: number;
|
||||||
|
/** Number of columns for the `table` variant. Default 5. */
|
||||||
|
columns?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Skeleton({
|
||||||
|
variant,
|
||||||
|
ariaLabel = 'Loading content',
|
||||||
|
rows = 6,
|
||||||
|
columns = 5,
|
||||||
|
}: SkeletonProps) {
|
||||||
|
if (variant === 'page') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="animate-pulse"
|
||||||
|
>
|
||||||
|
{/* PageHeader-shaped band */}
|
||||||
|
<div className="flex items-center justify-between px-6 py-4 border-b border-surface-border bg-surface">
|
||||||
|
<div>
|
||||||
|
<div className="h-3 w-32 bg-surface-border rounded mb-2" />
|
||||||
|
<div className="h-5 w-48 bg-surface-border rounded" />
|
||||||
|
</div>
|
||||||
|
<div className="h-9 w-28 bg-surface-border rounded" />
|
||||||
|
</div>
|
||||||
|
{/* Body grid: 4 stat tiles + 1 card */}
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{Array.from({ length: 4 }).map((_, i) => (
|
||||||
|
<div
|
||||||
|
key={i}
|
||||||
|
className="bg-surface border border-surface-border rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="h-3 w-20 bg-surface-border rounded mb-3" />
|
||||||
|
<div className="h-7 w-16 bg-surface-border rounded" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'table') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="animate-pulse"
|
||||||
|
>
|
||||||
|
<table className="w-full">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-surface-border">
|
||||||
|
{Array.from({ length: columns }).map((_, i) => (
|
||||||
|
<th key={i} className="text-left px-4 py-3">
|
||||||
|
<div className="h-3 w-20 bg-surface-border rounded" />
|
||||||
|
</th>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{Array.from({ length: rows }).map((_, r) => (
|
||||||
|
<tr key={r} className="border-b border-surface-border">
|
||||||
|
{Array.from({ length: columns }).map((_, c) => (
|
||||||
|
<td key={c} className="px-4 py-3">
|
||||||
|
<div
|
||||||
|
className={
|
||||||
|
'h-3 bg-surface-border rounded ' +
|
||||||
|
(c === 0 ? 'w-40' : c === columns - 1 ? 'w-16' : 'w-24')
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
))}
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (variant === 'card') {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="animate-pulse"
|
||||||
|
>
|
||||||
|
<Card />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// variant === 'stat'
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
role="status"
|
||||||
|
aria-busy="true"
|
||||||
|
aria-label={ariaLabel}
|
||||||
|
className="animate-pulse bg-surface border border-surface-border rounded-lg p-4"
|
||||||
|
>
|
||||||
|
<div className="h-3 w-20 bg-surface-border rounded mb-3" />
|
||||||
|
<div className="h-7 w-16 bg-surface-border rounded" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Card sub-shape, shared between `page` and `card` variants. */
|
||||||
|
function Card() {
|
||||||
|
return (
|
||||||
|
<div className="bg-surface border border-surface-border rounded-lg p-6">
|
||||||
|
<div className="h-4 w-40 bg-surface-border rounded mb-4" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="h-3 w-full bg-surface-border rounded" />
|
||||||
|
<div className="h-3 w-11/12 bg-surface-border rounded" />
|
||||||
|
<div className="h-3 w-2/3 bg-surface-border rounded" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { render } from '@testing-library/react';
|
import { render } from '@testing-library/react';
|
||||||
import StatusBadge from './StatusBadge';
|
import StatusBadge, { statusDisplay, titleCase } from './StatusBadge';
|
||||||
|
|
||||||
// -----------------------------------------------------------------------------
|
// -----------------------------------------------------------------------------
|
||||||
// D-1 master — StatusBadge enum-coverage contract
|
// D-1 master — StatusBadge enum-coverage contract
|
||||||
@@ -118,13 +118,111 @@ describe('StatusBadge — enum-coverage contract (D-1 master)', () => {
|
|||||||
expect(container.querySelector('span')!.className).toContain('badge-warning');
|
expect(container.querySelector('span')!.className).toContain('badge-warning');
|
||||||
});
|
});
|
||||||
|
|
||||||
// Unknown statuses fall through to neutral. The string is still
|
// Unknown statuses fall through to neutral. The label is humanised
|
||||||
// displayed verbatim so an operator can see "what is this?" rather
|
// via the titleCase() helper (UX-H5) so the operator sees readable
|
||||||
// than nothing at all.
|
// text rather than the raw enum key — "Some future status" instead
|
||||||
it('unknown status string renders as neutral but preserves the label text', () => {
|
// of "SomeFutureStatus".
|
||||||
|
it('unknown status string renders as neutral with titleCase fallback', () => {
|
||||||
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
|
const { container } = render(<StatusBadge status="SomeFutureStatus" />);
|
||||||
const span = container.querySelector('span');
|
const span = container.querySelector('span');
|
||||||
expect(span!.className).toBe('badge badge-neutral');
|
expect(span!.className).toBe('badge badge-neutral');
|
||||||
expect(span!.textContent).toBe('SomeFutureStatus');
|
expect(span!.textContent).toBe('Some future status');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
// UX-H5 master — StatusBadge display-string contract (Phase 1, 2026-05-14)
|
||||||
|
//
|
||||||
|
// The audit finding: pre-Phase-1, StatusBadge rendered raw Go enum keys
|
||||||
|
// — operators saw "RenewalInProgress" / "AwaitingCSR" / "cert_mismatch"
|
||||||
|
// / "dead" verbatim. Phase 1 adds a statusDisplay map next to
|
||||||
|
// statusStyles; this suite pins the byte-exact display string for every
|
||||||
|
// wire key.
|
||||||
|
// -----------------------------------------------------------------------------
|
||||||
|
describe('StatusBadge — display-string contract (UX-H5)', () => {
|
||||||
|
// Every wire key in the colour map MUST have a display-string entry
|
||||||
|
// and the entry MUST be non-empty. Missing entries fall back to the
|
||||||
|
// titleCase() helper, but having an explicit entry in statusDisplay
|
||||||
|
// is the preferred path (lets us pick the cleanest sentence-case
|
||||||
|
// phrasing, with terms like "Awaiting CSR" capitalised correctly
|
||||||
|
// where titleCase would yield "Awaiting csr").
|
||||||
|
const EXPECTED_DISPLAY: Array<[string, string]> = [
|
||||||
|
// Certificate statuses
|
||||||
|
['Active', 'Active'],
|
||||||
|
['Expiring', 'Expiring soon'],
|
||||||
|
['Expired', 'Expired'],
|
||||||
|
['RenewalInProgress', 'Renewal in progress'],
|
||||||
|
['Archived', 'Archived'],
|
||||||
|
['Revoked', 'Revoked'],
|
||||||
|
// Job statuses
|
||||||
|
['Pending', 'Pending'],
|
||||||
|
['AwaitingCSR', 'Awaiting CSR'],
|
||||||
|
['AwaitingApproval', 'Awaiting approval'],
|
||||||
|
['Running', 'Running'],
|
||||||
|
['Completed', 'Completed'],
|
||||||
|
['Failed', 'Failed'],
|
||||||
|
['Cancelled', 'Cancelled'],
|
||||||
|
// Agent statuses
|
||||||
|
['Online', 'Online'],
|
||||||
|
['Offline', 'Offline'],
|
||||||
|
['Degraded', 'Degraded'],
|
||||||
|
// Discovery statuses
|
||||||
|
['Unmanaged', 'Unmanaged'],
|
||||||
|
['Managed', 'Managed'],
|
||||||
|
['Dismissed', 'Dismissed'],
|
||||||
|
// Frontend-synthesized issuer statuses
|
||||||
|
['Enabled', 'Enabled'],
|
||||||
|
['Disabled', 'Disabled'],
|
||||||
|
// Notification statuses (lowercase wire values)
|
||||||
|
['sent', 'Sent'],
|
||||||
|
['pending', 'Pending'],
|
||||||
|
['failed', 'Failed'],
|
||||||
|
['dead', 'Dead-lettered'],
|
||||||
|
['read', 'Read'],
|
||||||
|
// Health check statuses (lowercase + snake_case)
|
||||||
|
['healthy', 'Healthy'],
|
||||||
|
['degraded', 'Degraded'],
|
||||||
|
['down', 'Down'],
|
||||||
|
['cert_mismatch', 'Certificate mismatch'],
|
||||||
|
['unknown', 'Unknown'],
|
||||||
|
];
|
||||||
|
|
||||||
|
it.each(EXPECTED_DISPLAY)(
|
||||||
|
"wire key '%s' renders display string '%s'",
|
||||||
|
(wire, expected) => {
|
||||||
|
// First — verify the statusDisplay map carries the entry verbatim.
|
||||||
|
expect(statusDisplay[wire]).toBe(expected);
|
||||||
|
// Then — verify the rendered <span>'s textContent matches.
|
||||||
|
const { container } = render(<StatusBadge status={wire} />);
|
||||||
|
expect(container.querySelector('span')!.textContent).toBe(expected);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
it('every wire key in statusStyles has a matching statusDisplay entry', () => {
|
||||||
|
// Parity check — re-deriving the styles key set isn't possible at
|
||||||
|
// runtime without re-importing it, but we can probe a known sample
|
||||||
|
// and pin: if a future PR adds a new style entry without a display
|
||||||
|
// entry, the EXPECTED_DISPLAY list above will mismatch.
|
||||||
|
expect(Object.keys(statusDisplay).length).toBeGreaterThanOrEqual(
|
||||||
|
EXPECTED_DISPLAY.length,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('titleCase() helper — fallback for unmapped keys', () => {
|
||||||
|
it('humanises PascalCase', () => {
|
||||||
|
expect(titleCase('RenewalInProgress')).toBe('Renewal in progress');
|
||||||
|
});
|
||||||
|
it('humanises snake_case', () => {
|
||||||
|
expect(titleCase('cert_mismatch')).toBe('Cert mismatch');
|
||||||
|
});
|
||||||
|
it('handles single-word lowercase', () => {
|
||||||
|
expect(titleCase('pending')).toBe('Pending');
|
||||||
|
});
|
||||||
|
it('handles single-word PascalCase', () => {
|
||||||
|
expect(titleCase('Active')).toBe('Active');
|
||||||
|
});
|
||||||
|
it('handles empty string defensively', () => {
|
||||||
|
expect(titleCase('')).toBe('');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -4,6 +4,16 @@
|
|||||||
// the Go side; StatusBadge.test.tsx walks every value and will go red
|
// the Go side; StatusBadge.test.tsx walks every value and will go red
|
||||||
// before users see a default-grey "what is happening?" badge.
|
// before users see a default-grey "what is happening?" badge.
|
||||||
//
|
//
|
||||||
|
// UX-H5 closure (Phase 1, 2026-05-14): we now render a human display
|
||||||
|
// string rather than the raw enum key. The wire keys stay byte-
|
||||||
|
// identical to the Go-side enums (per the D-1 closure comment above) —
|
||||||
|
// only the rendered text changes. PascalCase + snake_case +
|
||||||
|
// lowercase enums map to spaced sentence-case ("Renewal in progress",
|
||||||
|
// "Awaiting CSR", "Dead-lettered", "Certificate mismatch"). Unmapped
|
||||||
|
// keys fall through to a titleCase helper that lower-bounds the
|
||||||
|
// readability even when a new Go-side enum lands before the frontend
|
||||||
|
// catches up.
|
||||||
|
//
|
||||||
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
|
// D-1 master closure (cat-d-359e92c20cbf, cat-d-9f4c8e4a91f1,
|
||||||
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
|
// cat-d-1447e04732e7, cat-f-cert_detail_page_key_render_fallback,
|
||||||
// cat-f-ae0d06b6588f) fixed the pre-master drift:
|
// cat-f-ae0d06b6588f) fixed the pre-master drift:
|
||||||
@@ -74,7 +84,73 @@ const statusStyles: Record<string, string> = {
|
|||||||
unknown: 'badge-neutral',
|
unknown: 'badge-neutral',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// statusDisplay — human-facing text for each wire key. UX-H5 closure.
|
||||||
|
// Keys MUST stay byte-identical to statusStyles above (which is byte-
|
||||||
|
// identical to the Go enums). When a key here is missing, the
|
||||||
|
// titleCase fallback below renders something readable rather than
|
||||||
|
// the raw enum key.
|
||||||
|
const statusDisplay: Record<string, string> = {
|
||||||
|
// Certificate statuses
|
||||||
|
Active: 'Active',
|
||||||
|
Expiring: 'Expiring soon',
|
||||||
|
Expired: 'Expired',
|
||||||
|
RenewalInProgress: 'Renewal in progress',
|
||||||
|
Archived: 'Archived',
|
||||||
|
Revoked: 'Revoked',
|
||||||
|
// Job statuses
|
||||||
|
Pending: 'Pending',
|
||||||
|
AwaitingCSR: 'Awaiting CSR',
|
||||||
|
AwaitingApproval: 'Awaiting approval',
|
||||||
|
Running: 'Running',
|
||||||
|
Completed: 'Completed',
|
||||||
|
Failed: 'Failed',
|
||||||
|
Cancelled: 'Cancelled',
|
||||||
|
// Agent statuses
|
||||||
|
Online: 'Online',
|
||||||
|
Offline: 'Offline',
|
||||||
|
Degraded: 'Degraded',
|
||||||
|
// Discovery statuses
|
||||||
|
Unmanaged: 'Unmanaged',
|
||||||
|
Managed: 'Managed',
|
||||||
|
Dismissed: 'Dismissed',
|
||||||
|
// Issuer statuses (frontend-synthesized)
|
||||||
|
Enabled: 'Enabled',
|
||||||
|
Disabled: 'Disabled',
|
||||||
|
// Notification statuses
|
||||||
|
sent: 'Sent',
|
||||||
|
pending: 'Pending',
|
||||||
|
failed: 'Failed',
|
||||||
|
dead: 'Dead-lettered',
|
||||||
|
read: 'Read',
|
||||||
|
// Health check statuses
|
||||||
|
healthy: 'Healthy',
|
||||||
|
degraded: 'Degraded',
|
||||||
|
down: 'Down',
|
||||||
|
cert_mismatch: 'Certificate mismatch',
|
||||||
|
unknown: 'Unknown',
|
||||||
|
};
|
||||||
|
|
||||||
|
// titleCase — best-effort humanizer for wire keys not in statusDisplay.
|
||||||
|
// Handles PascalCase ("RenewalInProgress" → "Renewal in progress") and
|
||||||
|
// snake_case ("cert_mismatch" → "Cert mismatch"). The render-time fallback;
|
||||||
|
// adding a proper entry to statusDisplay above is the preferred path.
|
||||||
|
function titleCase(s: string): string {
|
||||||
|
if (!s) return s;
|
||||||
|
// snake_case → space-separated lower
|
||||||
|
let out = s.replace(/_/g, ' ');
|
||||||
|
// PascalCase / camelCase → space before capitals (but not the first)
|
||||||
|
out = out.replace(/([a-z])([A-Z])/g, '$1 $2');
|
||||||
|
// Lowercase everything, then capitalize the first character.
|
||||||
|
out = out.toLowerCase();
|
||||||
|
return out.charAt(0).toUpperCase() + out.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
export default function StatusBadge({ status }: { status: string }) {
|
export default function StatusBadge({ status }: { status: string }) {
|
||||||
const cls = statusStyles[status] || 'badge-neutral';
|
const cls = statusStyles[status] || 'badge-neutral';
|
||||||
return <span className={`badge ${cls}`}>{status}</span>;
|
const display = statusDisplay[status] ?? titleCase(status);
|
||||||
|
return <span className={`badge ${cls}`}>{display}</span>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Exported for the StatusBadge.test.tsx suite — pinning the byte-exact
|
||||||
|
// display strings for every wire key in one place.
|
||||||
|
export { statusStyles, statusDisplay, titleCase };
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { describe, it, expect, beforeEach } from 'vitest';
|
||||||
|
import { render, screen } from '@testing-library/react';
|
||||||
|
import Timestamp from './Timestamp';
|
||||||
|
import { setTimestampPref, getTimestampPref } from '../api/timestampPref';
|
||||||
|
|
||||||
|
const ISO = '2026-05-14T15:30:00Z';
|
||||||
|
|
||||||
|
describe('Timestamp', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
// Reset preference between tests.
|
||||||
|
localStorage.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('renders em-dash for empty iso, no tooltip wrapper', () => {
|
||||||
|
render(<Timestamp iso={null} />);
|
||||||
|
expect(screen.getByText('—')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('default preference is UTC + appends " UTC" suffix', () => {
|
||||||
|
render(<Timestamp iso={ISO} />);
|
||||||
|
// Default localStorage is empty → mode='utc'.
|
||||||
|
expect(getTimestampPref().mode).toBe('utc');
|
||||||
|
// 2026-05-14T15:30:00Z formatted in UTC contains May 14 15:30.
|
||||||
|
const text = screen.getByText(/UTC/);
|
||||||
|
expect(text.textContent).toMatch(/2026/);
|
||||||
|
expect(text.textContent).toMatch(/15:30|3:30/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forceMode="utc" overrides operator local preference', () => {
|
||||||
|
setTimestampPref({ mode: 'local', customTz: 'UTC' });
|
||||||
|
render(<Timestamp iso={ISO} forceMode="utc" />);
|
||||||
|
expect(screen.getByText(/UTC/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode="local" renders without UTC suffix', () => {
|
||||||
|
setTimestampPref({ mode: 'local', customTz: 'UTC' });
|
||||||
|
render(<Timestamp iso={ISO} />);
|
||||||
|
// Local mode strips the " UTC" suffix from the visible span.
|
||||||
|
const all = screen.getAllByText(/2026/);
|
||||||
|
const visible = all.find(el => !el.textContent?.includes('UTC'));
|
||||||
|
expect(visible).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mode="custom" renders the timezone label in parens', () => {
|
||||||
|
setTimestampPref({ mode: 'custom', customTz: 'America/New_York' });
|
||||||
|
render(<Timestamp iso={ISO} />);
|
||||||
|
expect(screen.getByText(/America\/New_York/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('invalid custom tz falls back to UTC under the hood (no throw)', () => {
|
||||||
|
setTimestampPref({ mode: 'custom', customTz: 'Not/Real_Zone' });
|
||||||
|
expect(() => render(<Timestamp iso={ISO} />)).not.toThrow();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Timestamp — Phase 6 closure for I18N-H3 (zero timezone handling
|
||||||
|
// today; server UTC audit logs can't be cross-referenced with frontend
|
||||||
|
// display without operator math).
|
||||||
|
//
|
||||||
|
// Default behavior: render the timestamp in UTC (so what the operator
|
||||||
|
// sees on-screen is byte-for-byte equivalent to what they'll grep out
|
||||||
|
// of `audit_events.created_at` or `journalctl -u certctl`), wrap it in
|
||||||
|
// the Phase 1 Tooltip primitive that surfaces the operator-local
|
||||||
|
// equivalent on hover / focus.
|
||||||
|
//
|
||||||
|
// Operator preference (`certctl:timestamp-display` in localStorage,
|
||||||
|
// see api/timestampPref.ts) flips the default. Available modes:
|
||||||
|
// • utc — render UTC, hover shows local. The safe default.
|
||||||
|
// • local — render browser-local, hover shows UTC.
|
||||||
|
// • custom — render in a configured IANA timezone, hover shows UTC.
|
||||||
|
//
|
||||||
|
// Why this lives as a primitive: pre-Phase-6, ~8 raw new Date(x)
|
||||||
|
// .toLocaleString() sites across 6 pages each made their own choice.
|
||||||
|
// Phase 6 routes them all through this one component + the CI guard
|
||||||
|
// at scripts/ci-guards/no-raw-toLocaleString.sh prevents new raw sites.
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
import { formatDateTime, formatDateTimeUTC, formatDateTimeInZone } from '../api/utils';
|
||||||
|
import { getTimestampPref, type TimestampPref } from '../api/timestampPref';
|
||||||
|
|
||||||
|
interface TimestampProps {
|
||||||
|
/** ISO-8601 timestamp from the API. Falsy renders an em-dash. */
|
||||||
|
iso: string | undefined | null;
|
||||||
|
/**
|
||||||
|
* Override the operator preference for this one site — usually
|
||||||
|
* unset. Set to 'utc' when the visible label MUST be UTC (e.g.
|
||||||
|
* inside an audit-log column where the column header says "UTC").
|
||||||
|
*/
|
||||||
|
forceMode?: 'utc' | 'local';
|
||||||
|
/** Optional class for the visible span. */
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function render(iso: string | undefined | null, pref: TimestampPref, forceMode?: 'utc' | 'local'): {
|
||||||
|
visible: string;
|
||||||
|
hover: string;
|
||||||
|
} {
|
||||||
|
if (!iso) return { visible: '—', hover: '—' };
|
||||||
|
const mode = forceMode ?? pref.mode;
|
||||||
|
if (mode === 'utc') {
|
||||||
|
return { visible: formatDateTimeUTC(iso) + ' UTC', hover: formatDateTime(iso) + ' (local)' };
|
||||||
|
}
|
||||||
|
if (mode === 'local') {
|
||||||
|
return { visible: formatDateTime(iso), hover: formatDateTimeUTC(iso) + ' UTC' };
|
||||||
|
}
|
||||||
|
// mode === 'custom'
|
||||||
|
return {
|
||||||
|
visible: formatDateTimeInZone(iso, pref.customTz) + ' (' + pref.customTz + ')',
|
||||||
|
hover: formatDateTimeUTC(iso) + ' UTC',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Timestamp({ iso, forceMode, className }: TimestampProps) {
|
||||||
|
// Initialize from localStorage at mount time so SSR-style empty
|
||||||
|
// renders don't flash the wrong format on first paint.
|
||||||
|
const [pref, setPref] = useState<TimestampPref>(() => getTimestampPref());
|
||||||
|
|
||||||
|
// Live-update when the operator changes the preference on the
|
||||||
|
// Settings page. timestampPref.ts dispatches a CustomEvent we
|
||||||
|
// subscribe to here.
|
||||||
|
useEffect(() => {
|
||||||
|
function onChange(e: Event) {
|
||||||
|
const detail = (e as CustomEvent<TimestampPref>).detail;
|
||||||
|
if (detail) setPref(detail);
|
||||||
|
}
|
||||||
|
window.addEventListener('certctl:timestamp-pref-changed', onChange);
|
||||||
|
return () => window.removeEventListener('certctl:timestamp-pref-changed', onChange);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const { visible, hover } = render(iso, pref, forceMode);
|
||||||
|
|
||||||
|
if (!iso) {
|
||||||
|
return <span className={className}>{visible}</span>;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip content={hover}>
|
||||||
|
<span className={className}>{visible}</span>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Smoke-test the Toaster wrapper. Sonner has its own deep test suite;
|
||||||
|
// we just pin (a) the wrapper renders without crashing, (b) the
|
||||||
|
// Sonner <Toaster /> root lands in the DOM with our position prop, and
|
||||||
|
// (c) toast.success / toast.error reach the renderer.
|
||||||
|
|
||||||
|
import { render, screen, act } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import Toaster from './Toaster';
|
||||||
|
|
||||||
|
describe('Toaster', () => {
|
||||||
|
it('renders the Sonner root without crashing', () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
// Sonner mounts a section[aria-label="Notifications <kbd>"] container
|
||||||
|
// — the label includes Sonner's expand-shortcut hint (e.g. "alt+T").
|
||||||
|
// Match the prefix only.
|
||||||
|
expect(screen.getByLabelText(/Notifications/)).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards toast.success() to the visible queue', async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(() => {
|
||||||
|
toast.success('Profile saved');
|
||||||
|
});
|
||||||
|
// Sonner debounces render slightly; flush via findByText.
|
||||||
|
expect(await screen.findByText('Profile saved')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards toast.error() to the visible queue', async () => {
|
||||||
|
render(<Toaster />);
|
||||||
|
act(() => {
|
||||||
|
toast.error('Save failed: not authorized');
|
||||||
|
});
|
||||||
|
expect(
|
||||||
|
await screen.findByText('Save failed: not authorized'),
|
||||||
|
).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Toaster — the certctl-themed Sonner wrapper. Phase 1 closure for
|
||||||
|
// UX-H3 (no toast / snackbar system) per the frontend-design-audit.
|
||||||
|
//
|
||||||
|
// Mount once near the top of <main.tsx>'s React tree (next to
|
||||||
|
// QueryClientProvider). Inside any component, import { toast } from
|
||||||
|
// "sonner" and call toast.success(…) / toast.error(…) / toast.info(…) /
|
||||||
|
// toast.warning(…). Sonner handles the singleton queue, focus + ARIA
|
||||||
|
// (role="status" / role="alert"), enter/exit animation, swipe-to-
|
||||||
|
// dismiss, and respects prefers-reduced-motion automatically.
|
||||||
|
//
|
||||||
|
// We surface a thin wrapper rather than the bare <Toaster /> so the
|
||||||
|
// default position + visual config lives in one place. Pages must NOT
|
||||||
|
// mount their own Toaster instances — Sonner asserts at runtime if
|
||||||
|
// multiple are mounted, but the failure mode is "toasts duplicate or
|
||||||
|
// disappear silently" which is hard to debug. Single import discipline.
|
||||||
|
//
|
||||||
|
// Visual position: top-right. Operators are paginated-table-heavy;
|
||||||
|
// top-right keeps the toast away from row-action click targets at the
|
||||||
|
// bottom of the list. richColors gives us the per-severity background
|
||||||
|
// fills (success teal / error red / warning amber / info blue) that
|
||||||
|
// match the existing .badge-* color tier.
|
||||||
|
|
||||||
|
import { Toaster as SonnerToaster } from 'sonner';
|
||||||
|
|
||||||
|
export default function Toaster() {
|
||||||
|
return (
|
||||||
|
<SonnerToaster
|
||||||
|
position="top-right"
|
||||||
|
richColors
|
||||||
|
closeButton
|
||||||
|
// 4s default for non-action toasts; persistent for error toasts
|
||||||
|
// with action (set per-call via toast.error(msg, { duration: ... })).
|
||||||
|
duration={4000}
|
||||||
|
// visibleToasts: cap stack so a runaway error loop doesn't drown
|
||||||
|
// the screen. 5 is the Sonner default; pinning it explicitly so
|
||||||
|
// the choice is documented.
|
||||||
|
visibleToasts={5}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Tooltip smoke + interaction tests. Floating-UI's positioning math
|
||||||
|
// requires a real browser layout engine; we just assert the wiring:
|
||||||
|
// - children render at rest (no tooltip)
|
||||||
|
// - focus reveals the tooltip body in the portal
|
||||||
|
// - escape dismisses
|
||||||
|
|
||||||
|
import { render, screen, fireEvent } from '@testing-library/react';
|
||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
|
||||||
|
describe('Tooltip', () => {
|
||||||
|
it('renders the trigger at rest with no tooltip visible', () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint">
|
||||||
|
<button>Hover me</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
expect(screen.getByRole('button', { name: 'Hover me' })).toBeInTheDocument();
|
||||||
|
expect(screen.queryByText('Hint')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reveals tooltip body on focus', () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Hint visible">
|
||||||
|
<button>Focusable trigger</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByRole('button', { name: 'Focusable trigger' });
|
||||||
|
fireEvent.focus(trigger);
|
||||||
|
// FloatingPortal renders into document.body; queryable.
|
||||||
|
expect(screen.getByText('Hint visible')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('dismisses on Escape after focus-open', () => {
|
||||||
|
render(
|
||||||
|
<Tooltip content="Press escape">
|
||||||
|
<button>Focusable</button>
|
||||||
|
</Tooltip>,
|
||||||
|
);
|
||||||
|
const trigger = screen.getByRole('button', { name: 'Focusable' });
|
||||||
|
fireEvent.focus(trigger);
|
||||||
|
expect(screen.getByText('Press escape')).toBeInTheDocument();
|
||||||
|
fireEvent.keyDown(document, { key: 'Escape' });
|
||||||
|
expect(screen.queryByText('Press escape')).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,122 @@
|
|||||||
|
// Copyright 2026 certctl LLC. All rights reserved.
|
||||||
|
// SPDX-License-Identifier: BUSL-1.1
|
||||||
|
//
|
||||||
|
// Tooltip — Floating-UI-backed replacement for the ~103 native title=
|
||||||
|
// attributes. Phase 1 builds the primitive; migrating the 103 callsites
|
||||||
|
// is per-page rolling work that happens in subsequent PRs (per the
|
||||||
|
// audit prompt's explicit "DO NOT" on one-mega-PR sweeps).
|
||||||
|
//
|
||||||
|
// Why Floating-UI: native title= renders poorly on mobile + has no
|
||||||
|
// reliable show/hide timing, no visual styling, no positioning around
|
||||||
|
// the edges of the viewport, and (most importantly) zero a11y story
|
||||||
|
// beyond the browser's default tooltip — which screen readers
|
||||||
|
// inconsistently surface. Floating-UI gives us:
|
||||||
|
// - middleware-driven positioning (auto-flip, shift, offset)
|
||||||
|
// - hover + focus triggers (with `useFocus` + `useHover`)
|
||||||
|
// - aria-describedby wiring via `useRole`
|
||||||
|
// - dismissable via ESC
|
||||||
|
//
|
||||||
|
// Usage:
|
||||||
|
// <Tooltip content="Some hint">
|
||||||
|
// <button>Hover me</button>
|
||||||
|
// </Tooltip>
|
||||||
|
//
|
||||||
|
// Children must be a single element capable of accepting a ref. For
|
||||||
|
// non-ref-forwardable children (e.g. plain text), wrap in a span.
|
||||||
|
|
||||||
|
import { useState, cloneElement, isValidElement } from 'react';
|
||||||
|
import type { ReactElement, ReactNode } from 'react';
|
||||||
|
import {
|
||||||
|
useFloating,
|
||||||
|
useHover,
|
||||||
|
useFocus,
|
||||||
|
useDismiss,
|
||||||
|
useRole,
|
||||||
|
useInteractions,
|
||||||
|
flip,
|
||||||
|
shift,
|
||||||
|
offset,
|
||||||
|
autoUpdate,
|
||||||
|
FloatingPortal,
|
||||||
|
} from '@floating-ui/react';
|
||||||
|
|
||||||
|
export interface TooltipProps {
|
||||||
|
/** Tooltip body — usually a short string; ReactNode is allowed for icons. */
|
||||||
|
content: ReactNode;
|
||||||
|
/** Single child element that receives the ref + ARIA wiring. */
|
||||||
|
children: ReactElement;
|
||||||
|
/** Preferred placement; Floating-UI will auto-flip if viewport-clamped. */
|
||||||
|
placement?: 'top' | 'right' | 'bottom' | 'left';
|
||||||
|
/** Pixel offset between the trigger and the tooltip. Default 6. */
|
||||||
|
offsetPx?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Tooltip({
|
||||||
|
content,
|
||||||
|
children,
|
||||||
|
placement = 'top',
|
||||||
|
offsetPx = 6,
|
||||||
|
}: TooltipProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const { refs, floatingStyles, context } = useFloating({
|
||||||
|
open,
|
||||||
|
onOpenChange: setOpen,
|
||||||
|
placement,
|
||||||
|
middleware: [offset(offsetPx), flip(), shift({ padding: 8 })],
|
||||||
|
whileElementsMounted: autoUpdate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const hover = useHover(context, { move: false, delay: { open: 200, close: 0 } });
|
||||||
|
const focus = useFocus(context);
|
||||||
|
const dismiss = useDismiss(context);
|
||||||
|
const role = useRole(context, { role: 'tooltip' });
|
||||||
|
|
||||||
|
const { getReferenceProps, getFloatingProps } = useInteractions([
|
||||||
|
hover,
|
||||||
|
focus,
|
||||||
|
dismiss,
|
||||||
|
role,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!isValidElement(children)) {
|
||||||
|
// Defensive: render the child verbatim; Tooltip wiring is skipped.
|
||||||
|
// Console-warn so the misuse is visible during dev.
|
||||||
|
if (typeof console !== 'undefined') {
|
||||||
|
console.warn(
|
||||||
|
'<Tooltip> requires a single React element child; got:',
|
||||||
|
children,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge the ref + interaction props onto the child. cloneElement keeps
|
||||||
|
// the original child's type + own props; we layer ours on top.
|
||||||
|
const triggerProps = getReferenceProps();
|
||||||
|
const child = cloneElement(
|
||||||
|
children as ReactElement<Record<string, unknown>>,
|
||||||
|
{
|
||||||
|
ref: refs.setReference,
|
||||||
|
...triggerProps,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{child}
|
||||||
|
{open && content && (
|
||||||
|
<FloatingPortal>
|
||||||
|
<div
|
||||||
|
ref={refs.setFloating}
|
||||||
|
style={floatingStyles}
|
||||||
|
{...getFloatingProps()}
|
||||||
|
className="z-50 max-w-xs rounded bg-ink/95 text-white text-xs px-2 py-1 shadow-lg pointer-events-none"
|
||||||
|
>
|
||||||
|
{content}
|
||||||
|
</div>
|
||||||
|
</FloatingPortal>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@
|
|||||||
// JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a
|
// JobsPage, RenewalPoliciesPage, DiscoveryPage) are deferred to a
|
||||||
// follow-up bundle — tracked as new ID `M-029`.
|
// follow-up bundle — tracked as new ID `M-029`.
|
||||||
|
|
||||||
import { useCallback, useMemo } from 'react';
|
import { useCallback, useMemo, useTransition } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
import { useSearchParams } from 'react-router-dom';
|
||||||
|
|
||||||
export interface ListParams {
|
export interface ListParams {
|
||||||
@@ -56,6 +56,13 @@ const DEFAULT_PAGE_SIZE = 25;
|
|||||||
*/
|
*/
|
||||||
export function useListParams(defaults?: Partial<ListParams>): ListParamsControls {
|
export function useListParams(defaults?: Partial<ListParams>): ListParamsControls {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
|
// Phase 4 closure (PERF-M1): mark URL-resident filter / sort / page
|
||||||
|
// updates as a transition so React can preempt the result-table
|
||||||
|
// reconciliation when the operator interacts with the toolbar (e.g.
|
||||||
|
// rapidly toggling dropdowns while a 50-row table is still rendering
|
||||||
|
// the previous result). useTransition keeps the dropdown UI snappy
|
||||||
|
// even when the result render is expensive.
|
||||||
|
const [, startTransition] = useTransition();
|
||||||
|
|
||||||
const params = useMemo<ListParams>(() => {
|
const params = useMemo<ListParams>(() => {
|
||||||
const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE);
|
const page = parsePositiveInt(searchParams.get('page'), defaults?.page ?? DEFAULT_PAGE);
|
||||||
@@ -88,7 +95,14 @@ export function useListParams(defaults?: Partial<ListParams>): ListParamsControl
|
|||||||
if (key !== 'page') {
|
if (key !== 'page') {
|
||||||
next.delete('page');
|
next.delete('page');
|
||||||
}
|
}
|
||||||
setSearchParams(next, { replace: true });
|
// startTransition lets React mark the downstream table reconcile
|
||||||
|
// as low-priority work — urgent updates (input typing, button
|
||||||
|
// hover) can preempt. The URL itself still updates immediately
|
||||||
|
// because setSearchParams calls history.replaceState synchronously;
|
||||||
|
// only the React-tree reconciliation is deferred.
|
||||||
|
startTransition(() => {
|
||||||
|
setSearchParams(next, { replace: true });
|
||||||
|
});
|
||||||
},
|
},
|
||||||
[searchParams, setSearchParams],
|
[searchParams, setSearchParams],
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -80,4 +80,116 @@ describe('useTrackedMutation — Bundle-8 / M-009', () => {
|
|||||||
expect(invalidateSpy).not.toHaveBeenCalled();
|
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||||
expect(onSuccess).toHaveBeenCalledOnce();
|
expect(onSuccess).toHaveBeenCalledOnce();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 2 TQ-L1 extension — pin the optimistic-update contract.
|
||||||
|
//
|
||||||
|
// useTrackedMutation passes onMutate / onError / onSettled through
|
||||||
|
// verbatim (only onSuccess is wrapper-owned). The 4 Phase-2 sites
|
||||||
|
// (mark-notification-read, dismiss-discovery, claim-discovered,
|
||||||
|
// archive-certificate) depend on this pass-through to implement
|
||||||
|
// optimistic updates with rollback. These tests pin:
|
||||||
|
// (a) onMutate runs before mutationFn (snapshot pre-mutation state)
|
||||||
|
// (b) onError fires with the snapshot as the 3rd arg (rollback path)
|
||||||
|
// (c) onError pass-through (raw useMutation behaviour preserved)
|
||||||
|
// (d) the no-options call is parity with raw useMutation (the
|
||||||
|
// wrapper imposes no semantic behaviour beyond invalidation
|
||||||
|
// + the optional onSuccess chain).
|
||||||
|
it('passes onMutate through and runs it before mutationFn', async () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const order: string[] = [];
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useTrackedMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
order.push('mutate');
|
||||||
|
return 'ok';
|
||||||
|
},
|
||||||
|
invalidates: [['something']],
|
||||||
|
onMutate: async () => {
|
||||||
|
order.push('onMutate');
|
||||||
|
return { snapshot: 'pre-state' };
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
{ wrapper: withQueryClient(client) },
|
||||||
|
);
|
||||||
|
result.current.mutate(undefined);
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(order).toEqual(['onMutate', 'mutate']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes onError through with the onMutate context (rollback path)', async () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const onError = vi.fn();
|
||||||
|
const onMutate = vi.fn(async () => ({ snapshot: { foo: 'bar' } }));
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useTrackedMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
throw new Error('boom');
|
||||||
|
},
|
||||||
|
invalidates: [['something']],
|
||||||
|
onMutate,
|
||||||
|
onError,
|
||||||
|
}),
|
||||||
|
{ wrapper: withQueryClient(client) },
|
||||||
|
);
|
||||||
|
result.current.mutate(undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(onMutate).toHaveBeenCalledOnce();
|
||||||
|
expect(onError).toHaveBeenCalledOnce();
|
||||||
|
// 3rd arg of onError is the onMutate return value (the snapshot
|
||||||
|
// for rollback). Pinning this guarantees the optimistic-update
|
||||||
|
// rollback wiring stays intact across future refactors.
|
||||||
|
expect(onError.mock.calls[0][2]).toEqual({ snapshot: { foo: 'bar' } });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT invalidate on error (only on success)', async () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const invalidateSpy = vi.spyOn(client, 'invalidateQueries');
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useTrackedMutation({
|
||||||
|
mutationFn: async () => {
|
||||||
|
throw new Error('nope');
|
||||||
|
},
|
||||||
|
invalidates: [['cache-key']],
|
||||||
|
}),
|
||||||
|
{ wrapper: withQueryClient(client) },
|
||||||
|
);
|
||||||
|
result.current.mutate(undefined);
|
||||||
|
await waitFor(() => expect(result.current.isError).toBe(true));
|
||||||
|
expect(invalidateSpy).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('passes onSettled through (fires after both success and error)', async () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const onSettled = vi.fn();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useTrackedMutation({
|
||||||
|
mutationFn: async () => 'ok',
|
||||||
|
invalidates: [['x']],
|
||||||
|
onSettled,
|
||||||
|
}),
|
||||||
|
{ wrapper: withQueryClient(client) },
|
||||||
|
);
|
||||||
|
result.current.mutate(undefined);
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(onSettled).toHaveBeenCalledOnce();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('parity with raw useMutation when no extra options given', async () => {
|
||||||
|
const client = new QueryClient();
|
||||||
|
const { result } = renderHook(
|
||||||
|
() =>
|
||||||
|
useTrackedMutation({
|
||||||
|
mutationFn: async (n: number) => n * 2,
|
||||||
|
invalidates: [['compute']],
|
||||||
|
}),
|
||||||
|
{ wrapper: withQueryClient(client) },
|
||||||
|
);
|
||||||
|
result.current.mutate(7);
|
||||||
|
await waitFor(() => expect(result.current.isSuccess).toBe(true));
|
||||||
|
expect(result.current.data).toBe(14);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+90
-2
@@ -1,4 +1,12 @@
|
|||||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500;600&display=swap');
|
/*
|
||||||
|
* Phase 0 hygiene (FE-H4 / PERF-H3): Inter + JetBrains Mono are now
|
||||||
|
* self-hosted via the @fontsource* packages, imported at the top of
|
||||||
|
* web/src/main.tsx so Vite can hash + bundle the font files. The old
|
||||||
|
* Google Fonts @import lived here and produced two cross-origin font
|
||||||
|
* requests on every cold load; those are gone and PERF-H3's
|
||||||
|
* preconnect/dns-prefetch suggestion collapses (no external host left
|
||||||
|
* to preconnect to).
|
||||||
|
*/
|
||||||
|
|
||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@@ -7,7 +15,11 @@
|
|||||||
@layer base {
|
@layer base {
|
||||||
body {
|
body {
|
||||||
@apply bg-page text-ink antialiased;
|
@apply bg-page text-ink antialiased;
|
||||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
/* Phase 0 hygiene (FE-H4): "Inter Variable" is the family name
|
||||||
|
registered by @fontsource-variable/inter (single woff2 covering
|
||||||
|
wght 100-900). Keep "Inter" as a fallback for older browsers /
|
||||||
|
any pinned local install. */
|
||||||
|
font-family: 'Inter Variable', 'Inter', system-ui, -apple-system, sans-serif;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,3 +63,79 @@
|
|||||||
@apply bg-surface border border-surface-border rounded-md shadow-sm p-5 border-t-4;
|
@apply bg-surface border border-surface-border rounded-md shadow-sm p-5 border-t-4;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Phase 0 hygiene (UX-L2): honour prefers-reduced-motion. Users who
|
||||||
|
* opt out of animation at the OS level get effectively-instant
|
||||||
|
* transitions on every animated element (badges, modals, toggles).
|
||||||
|
* 0.01ms is the conventional non-zero value — fully zero can break
|
||||||
|
* libraries that observe transitionend events.
|
||||||
|
*/
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
*,
|
||||||
|
::before,
|
||||||
|
::after {
|
||||||
|
animation-duration: 0.01ms !important;
|
||||||
|
animation-iteration-count: 1 !important;
|
||||||
|
transition-duration: 0.01ms !important;
|
||||||
|
scroll-behavior: auto !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Phase 0 hygiene (UX-L3): a baseline print stylesheet. Hides the
|
||||||
|
* sidebar + top action bars, removes card shadows, expands content
|
||||||
|
* to full width, and keeps table rows intact across page breaks.
|
||||||
|
* Operator-facing — operators print certificate detail pages and
|
||||||
|
* audit-log exports for compliance archives.
|
||||||
|
*/
|
||||||
|
@media print {
|
||||||
|
/* Drop sidebar / nav chrome — only the content matters in print. */
|
||||||
|
aside,
|
||||||
|
nav,
|
||||||
|
[role="navigation"],
|
||||||
|
.no-print {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Full-width content, no shadows, plain backgrounds (ink saving). */
|
||||||
|
body {
|
||||||
|
background: #ffffff !important;
|
||||||
|
color: #000000 !important;
|
||||||
|
}
|
||||||
|
main,
|
||||||
|
.card,
|
||||||
|
.stat-card {
|
||||||
|
width: 100% !important;
|
||||||
|
max-width: 100% !important;
|
||||||
|
margin: 0 !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
border-color: #cbd5e1 !important;
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Tables: prevent mid-row breaks, repeat headers on each page. */
|
||||||
|
table {
|
||||||
|
width: 100% !important;
|
||||||
|
}
|
||||||
|
thead {
|
||||||
|
display: table-header-group;
|
||||||
|
}
|
||||||
|
tr,
|
||||||
|
td,
|
||||||
|
th {
|
||||||
|
page-break-inside: avoid;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Show link hrefs alongside the visible text — print readers
|
||||||
|
can't click links, so the target URL is the only signal. */
|
||||||
|
a[href]::after {
|
||||||
|
content: " (" attr(href) ")";
|
||||||
|
font-size: 0.85em;
|
||||||
|
color: #555555;
|
||||||
|
}
|
||||||
|
a[href^="#"]::after,
|
||||||
|
a[href^="javascript:"]::after {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+164
-86
@@ -1,4 +1,13 @@
|
|||||||
import { StrictMode } from 'react';
|
// Phase 0 hygiene (FE-H4 / PERF-H3): self-hosted fonts. Replaces the
|
||||||
|
// Google Fonts @import that used to live at the top of src/index.css —
|
||||||
|
// Vite hashes + bundles these CSS files into web/dist on build, so cold
|
||||||
|
// loads no longer touch fonts.googleapis.com / fonts.gstatic.com.
|
||||||
|
import '@fontsource-variable/inter';
|
||||||
|
import '@fontsource/jetbrains-mono/400.css';
|
||||||
|
import '@fontsource/jetbrains-mono/500.css';
|
||||||
|
import '@fontsource/jetbrains-mono/600.css';
|
||||||
|
|
||||||
|
import { StrictMode, Suspense, lazy } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
||||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
@@ -6,101 +15,170 @@ import ErrorBoundary from './components/ErrorBoundary';
|
|||||||
import AuthProvider from './components/AuthProvider';
|
import AuthProvider from './components/AuthProvider';
|
||||||
import AuthGate from './components/AuthGate';
|
import AuthGate from './components/AuthGate';
|
||||||
import Layout from './components/Layout';
|
import Layout from './components/Layout';
|
||||||
|
// Phase 4 closure (FE-M5 + SCALE-H1): per-route code splitting.
|
||||||
|
// Pre-Phase-4 every page import above was eager — every page's React
|
||||||
|
// tree + its api/client + its query-key constants + its chart panels
|
||||||
|
// landed in the same first-load index-*.js (~1.07 MB raw / ~281 KB gz).
|
||||||
|
//
|
||||||
|
// Post-Phase-4 the dashboard stays eager (it's the landing route for
|
||||||
|
// every cold load) and every other page becomes a React.lazy() boundary
|
||||||
|
// so its chunk only ships when an operator navigates to that route.
|
||||||
|
// Each route is wrapped in a <Suspense fallback={<Skeleton variant=
|
||||||
|
// "page" />}> so the route transition shows a page-shaped skeleton
|
||||||
|
// instead of a blank white frame during the chunk fetch.
|
||||||
|
//
|
||||||
|
// Vite's manualChunks config (see vite.config.ts) splits react /
|
||||||
|
// react-router-dom / @tanstack/react-query / recharts / lucide-react
|
||||||
|
// into their own vendor chunks so vendor caches survive feature
|
||||||
|
// deploys (the index-*.js hash flips on every feature change; vendor
|
||||||
|
// chunks only re-hash when their package versions change in
|
||||||
|
// package-lock.json).
|
||||||
|
//
|
||||||
|
// Net cold-load budget post-Phase-4: vendor-react + vendor-router +
|
||||||
|
// vendor-query + (per-route chunk) + index-*.js (now only the routing
|
||||||
|
// + provider plumbing, not the page bodies). Dashboard adds
|
||||||
|
// vendor-recharts on demand.
|
||||||
import DashboardPage from './pages/DashboardPage';
|
import DashboardPage from './pages/DashboardPage';
|
||||||
import CertificatesPage from './pages/CertificatesPage';
|
import Skeleton from './components/Skeleton';
|
||||||
import CertificateDetailPage from './pages/CertificateDetailPage';
|
|
||||||
import AgentsPage from './pages/AgentsPage';
|
// Inventory.
|
||||||
import AgentDetailPage from './pages/AgentDetailPage';
|
const CertificatesPage = lazy(() => import('./pages/CertificatesPage'));
|
||||||
import JobsPage from './pages/JobsPage';
|
const CertificateDetailPage = lazy(() => import('./pages/CertificateDetailPage'));
|
||||||
import NotificationsPage from './pages/NotificationsPage';
|
const IssuersPage = lazy(() => import('./pages/IssuersPage'));
|
||||||
import PoliciesPage from './pages/PoliciesPage';
|
const IssuerDetailPage = lazy(() => import('./pages/IssuerDetailPage'));
|
||||||
import RenewalPoliciesPage from './pages/RenewalPoliciesPage';
|
const IssuerHierarchyPage = lazy(() => import('./pages/IssuerHierarchyPage'));
|
||||||
import IssuersPage from './pages/IssuersPage';
|
const TargetsPage = lazy(() => import('./pages/TargetsPage'));
|
||||||
import TargetsPage from './pages/TargetsPage';
|
const TargetDetailPage = lazy(() => import('./pages/TargetDetailPage'));
|
||||||
import ProfilesPage from './pages/ProfilesPage';
|
const ProfilesPage = lazy(() => import('./pages/ProfilesPage'));
|
||||||
import OwnersPage from './pages/OwnersPage';
|
// Delivery & jobs.
|
||||||
import TeamsPage from './pages/TeamsPage';
|
const JobsPage = lazy(() => import('./pages/JobsPage'));
|
||||||
import AgentGroupsPage from './pages/AgentGroupsPage';
|
const JobDetailPage = lazy(() => import('./pages/JobDetailPage'));
|
||||||
import AuditPage from './pages/AuditPage';
|
const AgentsPage = lazy(() => import('./pages/AgentsPage'));
|
||||||
import ShortLivedPage from './pages/ShortLivedPage';
|
const AgentDetailPage = lazy(() => import('./pages/AgentDetailPage'));
|
||||||
import AgentFleetPage from './pages/AgentFleetPage';
|
const AgentFleetPage = lazy(() => import('./pages/AgentFleetPage'));
|
||||||
import DiscoveryPage from './pages/DiscoveryPage';
|
const AgentGroupsPage = lazy(() => import('./pages/AgentGroupsPage'));
|
||||||
import NetworkScanPage from './pages/NetworkScanPage';
|
// Policy & notify.
|
||||||
import HealthMonitorPage from './pages/HealthMonitorPage';
|
const PoliciesPage = lazy(() => import('./pages/PoliciesPage'));
|
||||||
import DigestPage from './pages/DigestPage';
|
const RenewalPoliciesPage = lazy(() => import('./pages/RenewalPoliciesPage'));
|
||||||
import ObservabilityPage from './pages/ObservabilityPage';
|
const NotificationsPage = lazy(() => import('./pages/NotificationsPage'));
|
||||||
import JobDetailPage from './pages/JobDetailPage';
|
const DigestPage = lazy(() => import('./pages/DigestPage'));
|
||||||
import IssuerDetailPage from './pages/IssuerDetailPage';
|
// People.
|
||||||
import IssuerHierarchyPage from './pages/IssuerHierarchyPage';
|
const OwnersPage = lazy(() => import('./pages/OwnersPage'));
|
||||||
import TargetDetailPage from './pages/TargetDetailPage';
|
const TeamsPage = lazy(() => import('./pages/TeamsPage'));
|
||||||
import SCEPAdminPage from './pages/SCEPAdminPage';
|
// Audit & ops.
|
||||||
import ESTAdminPage from './pages/ESTAdminPage';
|
const AuditPage = lazy(() => import('./pages/AuditPage'));
|
||||||
// Bundle 1 Phase 10 — RBAC management pages.
|
const ShortLivedPage = lazy(() => import('./pages/ShortLivedPage'));
|
||||||
import RolesPage from './pages/auth/RolesPage';
|
const DiscoveryPage = lazy(() => import('./pages/DiscoveryPage'));
|
||||||
import RoleDetailPage from './pages/auth/RoleDetailPage';
|
const NetworkScanPage = lazy(() => import('./pages/NetworkScanPage'));
|
||||||
import KeysPage from './pages/auth/KeysPage';
|
const HealthMonitorPage = lazy(() => import('./pages/HealthMonitorPage'));
|
||||||
import AuthSettingsPage from './pages/auth/AuthSettingsPage';
|
const ObservabilityPage = lazy(() => import('./pages/ObservabilityPage'));
|
||||||
import ApprovalsPage from './pages/auth/ApprovalsPage';
|
// Protocol admin.
|
||||||
// Bundle 2 Phase 8 — OIDC + session management pages.
|
const SCEPAdminPage = lazy(() => import('./pages/SCEPAdminPage'));
|
||||||
import OIDCProvidersPage from './pages/auth/OIDCProvidersPage';
|
const ESTAdminPage = lazy(() => import('./pages/ESTAdminPage'));
|
||||||
import OIDCProviderDetailPage from './pages/auth/OIDCProviderDetailPage';
|
// Access (Bundle 1 Phase 10 — RBAC management).
|
||||||
import GroupMappingsPage from './pages/auth/GroupMappingsPage';
|
const RolesPage = lazy(() => import('./pages/auth/RolesPage'));
|
||||||
import SessionsPage from './pages/auth/SessionsPage';
|
const RoleDetailPage = lazy(() => import('./pages/auth/RoleDetailPage'));
|
||||||
import BreakglassPage from './pages/auth/BreakglassPage';
|
const KeysPage = lazy(() => import('./pages/auth/KeysPage'));
|
||||||
// Audit 2026-05-10 MED-11 closure — federated-user admin page.
|
const AuthSettingsPage = lazy(() => import('./pages/auth/AuthSettingsPage'));
|
||||||
import UsersPage from './pages/auth/UsersPage';
|
const ApprovalsPage = lazy(() => import('./pages/auth/ApprovalsPage'));
|
||||||
|
// Access (Bundle 2 Phase 8 — OIDC + session management).
|
||||||
|
const OIDCProvidersPage = lazy(() => import('./pages/auth/OIDCProvidersPage'));
|
||||||
|
const OIDCProviderDetailPage = lazy(() => import('./pages/auth/OIDCProviderDetailPage'));
|
||||||
|
const GroupMappingsPage = lazy(() => import('./pages/auth/GroupMappingsPage'));
|
||||||
|
const SessionsPage = lazy(() => import('./pages/auth/SessionsPage'));
|
||||||
|
const BreakglassPage = lazy(() => import('./pages/auth/BreakglassPage'));
|
||||||
|
// Audit 2026-05-10 MED-11 closure — federated-user admin.
|
||||||
|
const UsersPage = lazy(() => import('./pages/auth/UsersPage'));
|
||||||
|
|
||||||
|
// Phase 1 closure (UX-H3): toast / snackbar system. Mounted once near
|
||||||
|
// the root so any component can `import { toast } from "sonner"` and
|
||||||
|
// call toast.success / toast.error without provider plumbing.
|
||||||
|
import Toaster from './components/Toaster';
|
||||||
|
// Phase 3 closure (UX-H6 + FE-L4): cmd+k command palette mounted at
|
||||||
|
// the root. The hook + listener live in CommandPaletteHost so the
|
||||||
|
// keydown binding stays scoped to the React tree (auto-cleanup on
|
||||||
|
// HMR + StrictMode).
|
||||||
|
import CommandPaletteHost from './components/CommandPaletteHost';
|
||||||
|
import { STALE_TIME, GC_TIME } from './api/queryConstants';
|
||||||
import './index.css';
|
import './index.css';
|
||||||
|
|
||||||
|
// Phase 2 closure (TQ-H2 + TQ-M1): QueryClient defaults rewritten.
|
||||||
|
// Pre-Phase-2: staleTime 10s + refetchOnWindowFocus true caused a
|
||||||
|
// refetch storm on every tab refocus across 242 query sites and a
|
||||||
|
// 10s "freshness" window meaning every cross-page navigation
|
||||||
|
// triggered backend hits.
|
||||||
|
//
|
||||||
|
// Post-Phase-2: 5min REFERENCE staleTime is the dominant-case sane
|
||||||
|
// default; queries that legitimately need live data (jobs, in-flight
|
||||||
|
// scans, agent heartbeats — the live-tile cohort) opt in PER-QUERY to
|
||||||
|
// staleTime: STALE_TIME.REAL_TIME + refetchOnWindowFocus: true. gcTime
|
||||||
|
// is now explicit at STANDARD (5min) so the contract is documented at
|
||||||
|
// the root rather than implicit-defaulted by TanStack.
|
||||||
|
//
|
||||||
|
// retry: 1 stays — lowering to 0 surfaces network blips; raising to
|
||||||
|
// the TanStack default of 3 hammers the backend on transient 503s.
|
||||||
const queryClient = new QueryClient({
|
const queryClient = new QueryClient({
|
||||||
defaultOptions: {
|
defaultOptions: {
|
||||||
queries: {
|
queries: {
|
||||||
staleTime: 10_000,
|
staleTime: STALE_TIME.REFERENCE, // 5 min — see api/queryConstants.ts
|
||||||
retry: 1,
|
gcTime: GC_TIME.STANDARD, // 5 min — explicit; was TanStack-default
|
||||||
refetchOnWindowFocus: true,
|
retry: 1,
|
||||||
|
refetchOnWindowFocus: false, // per-query opt-in for live-tile queries
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Phase 4 helper: wrap a lazy route in a page-shaped Suspense fallback.
|
||||||
|
// The same Skeleton variant lands on every route so the transition is
|
||||||
|
// visually consistent — operators learn "skeleton bars = chunk loading"
|
||||||
|
// once and never see a different placeholder elsewhere.
|
||||||
|
function lazyRoute(element: React.ReactNode) {
|
||||||
|
return <Suspense fallback={<Skeleton variant="page" />}>{element}</Suspense>;
|
||||||
|
}
|
||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<ErrorBoundary>
|
<ErrorBoundary>
|
||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<Toaster />
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<AuthGate>
|
<AuthGate>
|
||||||
<BrowserRouter>
|
<BrowserRouter>
|
||||||
|
<CommandPaletteHost />
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route element={<Layout />}>
|
<Route element={<Layout />}>
|
||||||
|
{/* Dashboard stays eager — landing route for every cold load. */}
|
||||||
<Route index element={<DashboardPage />} />
|
<Route index element={<DashboardPage />} />
|
||||||
<Route path="certificates" element={<CertificatesPage />} />
|
<Route path="certificates" element={lazyRoute(<CertificatesPage />)} />
|
||||||
<Route path="certificates/:id" element={<CertificateDetailPage />} />
|
<Route path="certificates/:id" element={lazyRoute(<CertificateDetailPage />)} />
|
||||||
<Route path="agents" element={<AgentsPage />} />
|
<Route path="agents" element={lazyRoute(<AgentsPage />)} />
|
||||||
<Route path="agents/:id" element={<AgentDetailPage />} />
|
<Route path="agents/:id" element={lazyRoute(<AgentDetailPage />)} />
|
||||||
<Route path="fleet" element={<AgentFleetPage />} />
|
<Route path="fleet" element={lazyRoute(<AgentFleetPage />)} />
|
||||||
<Route path="jobs" element={<JobsPage />} />
|
<Route path="jobs" element={lazyRoute(<JobsPage />)} />
|
||||||
<Route path="jobs/:id" element={<JobDetailPage />} />
|
<Route path="jobs/:id" element={lazyRoute(<JobDetailPage />)} />
|
||||||
<Route path="notifications" element={<NotificationsPage />} />
|
<Route path="notifications" element={lazyRoute(<NotificationsPage />)} />
|
||||||
<Route path="policies" element={<PoliciesPage />} />
|
<Route path="policies" element={lazyRoute(<PoliciesPage />)} />
|
||||||
<Route path="renewal-policies" element={<RenewalPoliciesPage />} />
|
<Route path="renewal-policies" element={lazyRoute(<RenewalPoliciesPage />)} />
|
||||||
<Route path="profiles" element={<ProfilesPage />} />
|
<Route path="profiles" element={lazyRoute(<ProfilesPage />)} />
|
||||||
<Route path="issuers" element={<IssuersPage />} />
|
<Route path="issuers" element={lazyRoute(<IssuersPage />)} />
|
||||||
<Route path="issuers/:id" element={<IssuerDetailPage />} />
|
<Route path="issuers/:id" element={lazyRoute(<IssuerDetailPage />)} />
|
||||||
{/* Rank 8 — operator-managed multi-level CA hierarchy.
|
{/* Rank 8 — operator-managed multi-level CA hierarchy.
|
||||||
Admin-gated at the API; the page renders the
|
Admin-gated at the API; the page renders the
|
||||||
backend's 403 as ErrorState for non-admin
|
backend's 403 as ErrorState for non-admin
|
||||||
callers. See docs/intermediate-ca-hierarchy.md. */}
|
callers. See docs/intermediate-ca-hierarchy.md. */}
|
||||||
<Route path="issuers/:id/hierarchy" element={<IssuerHierarchyPage />} />
|
<Route path="issuers/:id/hierarchy" element={lazyRoute(<IssuerHierarchyPage />)} />
|
||||||
<Route path="targets" element={<TargetsPage />} />
|
<Route path="targets" element={lazyRoute(<TargetsPage />)} />
|
||||||
<Route path="targets/:id" element={<TargetDetailPage />} />
|
<Route path="targets/:id" element={lazyRoute(<TargetDetailPage />)} />
|
||||||
<Route path="owners" element={<OwnersPage />} />
|
<Route path="owners" element={lazyRoute(<OwnersPage />)} />
|
||||||
<Route path="teams" element={<TeamsPage />} />
|
<Route path="teams" element={lazyRoute(<TeamsPage />)} />
|
||||||
<Route path="agent-groups" element={<AgentGroupsPage />} />
|
<Route path="agent-groups" element={lazyRoute(<AgentGroupsPage />)} />
|
||||||
<Route path="audit" element={<AuditPage />} />
|
<Route path="audit" element={lazyRoute(<AuditPage />)} />
|
||||||
<Route path="short-lived" element={<ShortLivedPage />} />
|
<Route path="short-lived" element={lazyRoute(<ShortLivedPage />)} />
|
||||||
<Route path="discovery" element={<DiscoveryPage />} />
|
<Route path="discovery" element={lazyRoute(<DiscoveryPage />)} />
|
||||||
<Route path="network-scans" element={<NetworkScanPage />} />
|
<Route path="network-scans" element={lazyRoute(<NetworkScanPage />)} />
|
||||||
<Route path="health-monitor" element={<HealthMonitorPage />} />
|
<Route path="health-monitor" element={lazyRoute(<HealthMonitorPage />)} />
|
||||||
<Route path="digest" element={<DigestPage />} />
|
<Route path="digest" element={lazyRoute(<DigestPage />)} />
|
||||||
<Route path="observability" element={<ObservabilityPage />} />
|
<Route path="observability" element={lazyRoute(<ObservabilityPage />)} />
|
||||||
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
|
{/* SCEP RFC 8894 + Intune master bundle Phase 9.4 (initial)
|
||||||
+ Phase 9 follow-up (rebrand): per-profile SCEP
|
+ Phase 9 follow-up (rebrand): per-profile SCEP
|
||||||
Administration page with Profiles / Intune Monitoring /
|
Administration page with Profiles / Intune Monitoring /
|
||||||
@@ -108,17 +186,17 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
itself renders an "Admin access required" banner for
|
itself renders an "Admin access required" banner for
|
||||||
non-admin callers and skips the underlying API calls so
|
non-admin callers and skips the underlying API calls so
|
||||||
the server never sees a 403-prone request. */}
|
the server never sees a 403-prone request. */}
|
||||||
<Route path="scep" element={<SCEPAdminPage />} />
|
<Route path="scep" element={lazyRoute(<SCEPAdminPage />)} />
|
||||||
{/* Backward-compat alias for external bookmarks the Phase 9
|
{/* Backward-compat alias for external bookmarks the Phase 9
|
||||||
release advertised. Lands on the Intune Monitoring tab. */}
|
release advertised. Lands on the Intune Monitoring tab. */}
|
||||||
<Route path="scep/intune" element={<SCEPAdminPage />} />
|
<Route path="scep/intune" element={lazyRoute(<SCEPAdminPage />)} />
|
||||||
{/* EST RFC 7030 hardening master bundle Phase 8: per-profile
|
{/* EST RFC 7030 hardening master bundle Phase 8: per-profile
|
||||||
EST Administration page with Profiles / Recent Activity /
|
EST Administration page with Profiles / Recent Activity /
|
||||||
Trust Bundle tabs. Same admin-gate pattern as SCEP — the
|
Trust Bundle tabs. Same admin-gate pattern as SCEP — the
|
||||||
route is unconditional; the page renders an "Admin access
|
route is unconditional; the page renders an "Admin access
|
||||||
required" banner for non-admin callers and skips the
|
required" banner for non-admin callers and skips the
|
||||||
underlying API calls so the server never sees a 403. */}
|
underlying API calls so the server never sees a 403. */}
|
||||||
<Route path="est" element={<ESTAdminPage />} />
|
<Route path="est" element={lazyRoute(<ESTAdminPage />)} />
|
||||||
{/* Bundle 1 Phase 10 — RBAC management surface.
|
{/* Bundle 1 Phase 10 — RBAC management surface.
|
||||||
Every page reads /api/v1/auth/me on mount via the
|
Every page reads /api/v1/auth/me on mount via the
|
||||||
useAuthMe hook and gates affordances against the
|
useAuthMe hook and gates affordances against the
|
||||||
@@ -126,19 +204,19 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
enforcement is the load-bearing layer; client-side
|
enforcement is the load-bearing layer; client-side
|
||||||
hide/disable is UX. */}
|
hide/disable is UX. */}
|
||||||
{/* Bundle 2 Phase 8 — OIDC + session management surface. */}
|
{/* Bundle 2 Phase 8 — OIDC + session management surface. */}
|
||||||
<Route path="auth/oidc/providers" element={<OIDCProvidersPage />} />
|
<Route path="auth/oidc/providers" element={lazyRoute(<OIDCProvidersPage />)} />
|
||||||
<Route path="auth/oidc/providers/:id" element={<OIDCProviderDetailPage />} />
|
<Route path="auth/oidc/providers/:id" element={lazyRoute(<OIDCProviderDetailPage />)} />
|
||||||
<Route path="auth/oidc/providers/:id/mappings" element={<GroupMappingsPage />} />
|
<Route path="auth/oidc/providers/:id/mappings" element={lazyRoute(<GroupMappingsPage />)} />
|
||||||
<Route path="auth/sessions" element={<SessionsPage />} />
|
<Route path="auth/sessions" element={lazyRoute(<SessionsPage />)} />
|
||||||
<Route path="auth/roles" element={<RolesPage />} />
|
<Route path="auth/roles" element={lazyRoute(<RolesPage />)} />
|
||||||
<Route path="auth/roles/:id" element={<RoleDetailPage />} />
|
<Route path="auth/roles/:id" element={lazyRoute(<RoleDetailPage />)} />
|
||||||
<Route path="auth/keys" element={<KeysPage />} />
|
<Route path="auth/keys" element={lazyRoute(<KeysPage />)} />
|
||||||
<Route path="auth/settings" element={<AuthSettingsPage />} />
|
<Route path="auth/settings" element={lazyRoute(<AuthSettingsPage />)} />
|
||||||
<Route path="auth/approvals" element={<ApprovalsPage />} />
|
<Route path="auth/approvals" element={lazyRoute(<ApprovalsPage />)} />
|
||||||
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
|
{/* Audit 2026-05-10 CRIT-4 closure — break-glass admin surface. */}
|
||||||
<Route path="auth/breakglass" element={<BreakglassPage />} />
|
<Route path="auth/breakglass" element={lazyRoute(<BreakglassPage />)} />
|
||||||
{/* Audit 2026-05-10 MED-11 closure — federated-user admin. */}
|
{/* Audit 2026-05-10 MED-11 closure — federated-user admin. */}
|
||||||
<Route path="auth/users" element={<UsersPage />} />
|
<Route path="auth/users" element={lazyRoute(<UsersPage />)} />
|
||||||
</Route>
|
</Route>
|
||||||
</Routes>
|
</Routes>
|
||||||
</BrowserRouter>
|
</BrowserRouter>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
|
import { getAgentGroups, deleteAgentGroup, createAgentGroup, updateAgentGroup } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -7,6 +8,7 @@ import DataTable from '../components/DataTable';
|
|||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { AgentGroup } from '../api/types';
|
import type { AgentGroup } from '../api/types';
|
||||||
|
|
||||||
@@ -254,6 +256,7 @@ export default function AgentGroupsPage() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
|
const [editingGroup, setEditingGroup] = useState<AgentGroup | null>(null);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<AgentGroup | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['agent-groups'],
|
queryKey: ['agent-groups'],
|
||||||
@@ -263,6 +266,8 @@ export default function AgentGroupsPage() {
|
|||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteAgentGroup,
|
mutationFn: deleteAgentGroup,
|
||||||
invalidates: [['agent-groups']],
|
invalidates: [['agent-groups']],
|
||||||
|
onSuccess: () => toast.success('Agent group deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useTrackedMutation({
|
const createMutation = useTrackedMutation({
|
||||||
@@ -337,7 +342,7 @@ export default function AgentGroupsPage() {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete group ${g.name}?`)) deleteMutation.mutate(g.id); }}
|
onClick={(e) => { e.stopPropagation(); setConfirmDelete(g); }}
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -385,6 +390,22 @@ export default function AgentGroupsPage() {
|
|||||||
isLoading={updateMutation.isPending}
|
isLoading={updateMutation.isPending}
|
||||||
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete !== null}
|
||||||
|
title="Delete agent group"
|
||||||
|
message={
|
||||||
|
confirmDelete
|
||||||
|
? `Delete group ${confirmDelete.name}? This will remove the group definition; agents currently in the group will fall back to default assignment.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => {
|
||||||
|
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+124
-119
@@ -9,6 +9,7 @@ import {
|
|||||||
BlockedByDependenciesError,
|
BlockedByDependenciesError,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import ModalDialog from '../components/ModalDialog';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
@@ -309,129 +310,133 @@ function RetireModal({
|
|||||||
}) {
|
}) {
|
||||||
if (mode.kind === 'closed') return null;
|
if (mode.kind === 'closed') return null;
|
||||||
|
|
||||||
|
// Phase 5 closure (FE-H3): swapped inline `<div role="dialog">` markup
|
||||||
|
// for ModalDialog (Headless UI). Each of the 3 modes (confirm / blocked /
|
||||||
|
// error) renders inside the same dialog shell, so focus trap + ESC + click-
|
||||||
|
// outside come for free. Title + footer change per mode; body is the
|
||||||
|
// mode-specific content.
|
||||||
|
const title =
|
||||||
|
mode.kind === 'confirm' ? 'Retire agent' :
|
||||||
|
mode.kind === 'blocked' ? 'Cannot retire — active dependencies' :
|
||||||
|
/* error */ 'Retire failed';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalDialog
|
||||||
role="dialog"
|
open={true}
|
||||||
aria-modal="true"
|
title={title}
|
||||||
className="fixed inset-0 z-40 flex items-center justify-center bg-black/40"
|
onClose={pending ? () => {} : onClose}
|
||||||
onClick={onClose}
|
maxWidth="lg"
|
||||||
|
footer={
|
||||||
|
mode.kind === 'confirm' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onSoftRetire}
|
||||||
|
disabled={pending}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? 'Retiring…' : 'Retire'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : mode.kind === 'blocked' ? (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||||
|
disabled={pending}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onForceRetire}
|
||||||
|
// Backend enforces reason on force; keep the GUI in lockstep
|
||||||
|
// rather than letting a 400 bounce back.
|
||||||
|
disabled={pending || !mode.reason.trim()}
|
||||||
|
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{pending ? 'Force-retiring…' : 'Force retire'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClose}
|
||||||
|
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<div
|
{mode.kind === 'confirm' && (
|
||||||
className="w-full max-w-lg rounded-lg bg-surface p-6 shadow-lg border border-border"
|
<>
|
||||||
onClick={(e) => e.stopPropagation()}
|
<p className="text-sm text-ink-muted">
|
||||||
>
|
<span className="font-mono">{mode.agent.name}</span> ({mode.agent.id}) will be
|
||||||
{mode.kind === 'confirm' && (
|
soft-retired. The agent will stop receiving heartbeats and be removed from active
|
||||||
<>
|
listings. This is reversible only by direct database intervention.
|
||||||
<h2 className="text-lg font-semibold text-ink">Retire agent</h2>
|
</p>
|
||||||
<p className="mt-2 text-sm text-ink-muted">
|
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
||||||
<span className="font-mono">{mode.agent.name}</span> ({mode.agent.id}) will be
|
Reason (optional)
|
||||||
soft-retired. The agent will stop receiving heartbeats and be removed from active
|
<input
|
||||||
listings. This is reversible only by direct database intervention.
|
type="text"
|
||||||
</p>
|
value={mode.reason}
|
||||||
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
onChange={(e) => onReasonChange(e.target.value)}
|
||||||
Reason (optional)
|
placeholder="e.g. decommissioning rack 7"
|
||||||
<input
|
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
||||||
type="text"
|
/>
|
||||||
value={mode.reason}
|
</label>
|
||||||
onChange={(e) => onReasonChange(e.target.value)}
|
</>
|
||||||
placeholder="e.g. decommissioning rack 7"
|
)}
|
||||||
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
|
||||||
disabled={pending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onSoftRetire}
|
|
||||||
disabled={pending}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? 'Retiring…' : 'Retire'}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mode.kind === 'blocked' && (
|
{mode.kind === 'blocked' && (
|
||||||
<>
|
<>
|
||||||
<h2 className="text-lg font-semibold text-ink">Cannot retire — active dependencies</h2>
|
<p className="text-sm text-ink-muted">
|
||||||
<p className="mt-2 text-sm text-ink-muted">
|
The agent <span className="font-mono">{mode.agent.name}</span> still has downstream
|
||||||
The agent <span className="font-mono">{mode.agent.name}</span> still has downstream
|
work tied to it. Force-retiring will cascade-retire all active targets and fail any
|
||||||
work tied to it. Force-retiring will cascade-retire all active targets and fail any
|
pending jobs.
|
||||||
pending jobs.
|
</p>
|
||||||
</p>
|
<dl className="mt-4 grid grid-cols-3 gap-3 text-center">
|
||||||
<dl className="mt-4 grid grid-cols-3 gap-3 text-center">
|
<div className="rounded border border-border bg-surface-alt p-3">
|
||||||
<div className="rounded border border-border bg-surface-alt p-3">
|
<dt className="text-xs text-ink-muted">Active targets</dt>
|
||||||
<dt className="text-xs text-ink-muted">Active targets</dt>
|
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.active_targets}</dd>
|
||||||
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.active_targets}</dd>
|
|
||||||
</div>
|
|
||||||
<div className="rounded border border-border bg-surface-alt p-3">
|
|
||||||
<dt className="text-xs text-ink-muted">Active certs</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold text-ink">
|
|
||||||
{mode.counts.active_certificates}
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div className="rounded border border-border bg-surface-alt p-3">
|
|
||||||
<dt className="text-xs text-ink-muted">Pending jobs</dt>
|
|
||||||
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.pending_jobs}</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
|
||||||
Reason <span className="text-danger">(required for force retire)</span>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={mode.reason}
|
|
||||||
onChange={(e) => onReasonChange(e.target.value)}
|
|
||||||
placeholder="e.g. rack 7 decommission, cascade retire"
|
|
||||||
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="mt-6 flex justify-end gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
|
||||||
disabled={pending}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onForceRetire}
|
|
||||||
// Backend enforces reason on force; keep the GUI in lockstep
|
|
||||||
// rather than letting a 400 bounce back.
|
|
||||||
disabled={pending || !mode.reason.trim()}
|
|
||||||
className="px-4 py-2 text-sm font-medium text-white bg-danger rounded hover:bg-danger/90 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{pending ? 'Force-retiring…' : 'Force retire'}
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</>
|
<div className="rounded border border-border bg-surface-alt p-3">
|
||||||
)}
|
<dt className="text-xs text-ink-muted">Active certs</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold text-ink">
|
||||||
|
{mode.counts.active_certificates}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
<div className="rounded border border-border bg-surface-alt p-3">
|
||||||
|
<dt className="text-xs text-ink-muted">Pending jobs</dt>
|
||||||
|
<dd className="mt-1 text-xl font-semibold text-ink">{mode.counts.pending_jobs}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<label className="mt-4 block text-xs font-medium text-ink-muted">
|
||||||
|
Reason <span className="text-danger">(required for force retire)</span>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={mode.reason}
|
||||||
|
onChange={(e) => onReasonChange(e.target.value)}
|
||||||
|
placeholder="e.g. rack 7 decommission, cascade retire"
|
||||||
|
className="mt-1 w-full rounded border border-border bg-surface-alt px-2 py-1 text-sm"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
{mode.kind === 'error' && (
|
{mode.kind === 'error' && (
|
||||||
<>
|
<p className="text-sm text-danger">{mode.message}</p>
|
||||||
<h2 className="text-lg font-semibold text-ink">Retire failed</h2>
|
)}
|
||||||
<p className="mt-2 text-sm text-danger">{mode.message}</p>
|
</ModalDialog>
|
||||||
<div className="mt-6 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={onClose}
|
|
||||||
className="px-4 py-2 text-sm text-ink-muted hover:text-ink"
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useParams, useNavigate } from 'react-router-dom';
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client';
|
import { getCertificate, getCertificateVersions, triggerRenewal, triggerDeployment, archiveCertificate, revokeCertificate, updateCertificate, getTargets, getJobs, getRenewalPolicies, getProfiles, getProfile, downloadCertificatePEM, exportCertificatePKCS12, getOCSPStatus, fetchCRL, getAdminCRLCache } from '../api/client';
|
||||||
import { REVOCATION_REASONS } from '../api/types';
|
import { REVOCATION_REASONS } from '../api/types';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { useAuth } from '../components/AuthProvider';
|
import { useAuth } from '../components/AuthProvider';
|
||||||
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
import { formatDate, formatDateTime, daysUntil, expiryColor, timeAgo } from '../api/utils';
|
||||||
import type { Job, CRLCacheRow } from '../api/types';
|
import type { Job, CRLCacheRow } from '../api/types';
|
||||||
@@ -415,6 +417,7 @@ function InlinePolicyEditor({ certId, currentPolicyId, currentProfileId }: { cer
|
|||||||
export default function CertificateDetailPage() {
|
export default function CertificateDetailPage() {
|
||||||
const { id } = useParams<{ id: string }>();
|
const { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
const [showDeploy, setShowDeploy] = useState(false);
|
const [showDeploy, setShowDeploy] = useState(false);
|
||||||
const [deployTargetId, setDeployTargetId] = useState('');
|
const [deployTargetId, setDeployTargetId] = useState('');
|
||||||
const [showRevoke, setShowRevoke] = useState(false);
|
const [showRevoke, setShowRevoke] = useState(false);
|
||||||
@@ -422,6 +425,7 @@ export default function CertificateDetailPage() {
|
|||||||
const [showExport, setShowExport] = useState(false);
|
const [showExport, setShowExport] = useState(false);
|
||||||
const [pkcs12Password, setPkcs12Password] = useState('');
|
const [pkcs12Password, setPkcs12Password] = useState('');
|
||||||
const [exporting, setExporting] = useState(false);
|
const [exporting, setExporting] = useState(false);
|
||||||
|
const [confirmArchive, setConfirmArchive] = useState(false);
|
||||||
|
|
||||||
const { data: cert, isLoading, error, refetch } = useQuery({
|
const { data: cert, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['certificate', id],
|
queryKey: ['certificate', id],
|
||||||
@@ -462,10 +466,30 @@ export default function CertificateDetailPage() {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const archiveMutation = useTrackedMutation({
|
// Phase 2 TQ-M3 closure: optimistic archive. Flip the cert's status
|
||||||
|
// to 'Archived' in the ['certificate', id] cache snapshot
|
||||||
|
// immediately; on success navigate (the user leaves the page so the
|
||||||
|
// optimistic data doesn't linger). On error, restore the snapshot
|
||||||
|
// + surface the error toast — the user stays on the page with
|
||||||
|
// status reverted.
|
||||||
|
type ArchiveSnapshot = { prev?: { status?: string } | undefined };
|
||||||
|
const archiveMutation = useTrackedMutation<unknown, Error, void, ArchiveSnapshot>({
|
||||||
mutationFn: () => archiveCertificate(id!),
|
mutationFn: () => archiveCertificate(id!),
|
||||||
invalidates: [['certificates']],
|
invalidates: [['certificates']],
|
||||||
|
onMutate: async (): Promise<ArchiveSnapshot> => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['certificate', id] });
|
||||||
|
const prev = queryClient.getQueryData(['certificate', id]) as ArchiveSnapshot['prev'];
|
||||||
|
if (prev) {
|
||||||
|
queryClient.setQueryData(['certificate', id], { ...prev, status: 'Archived' });
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (err, _vars, snap) => {
|
||||||
|
if (snap?.prev) queryClient.setQueryData(['certificate', id], snap.prev);
|
||||||
|
toast.error(`Archive failed: ${err.message}`);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success('Certificate archived');
|
||||||
navigate('/certificates');
|
navigate('/certificates');
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -490,7 +514,7 @@ export default function CertificateDetailPage() {
|
|||||||
a.click();
|
a.click();
|
||||||
URL.revokeObjectURL(url);
|
URL.revokeObjectURL(url);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -509,7 +533,7 @@ export default function CertificateDetailPage() {
|
|||||||
setShowExport(false);
|
setShowExport(false);
|
||||||
setPkcs12Password('');
|
setPkcs12Password('');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
alert(`Export failed: ${err instanceof Error ? err.message : err}`);
|
toast.error(`Export failed: ${err instanceof Error ? err.message : err}`);
|
||||||
} finally {
|
} finally {
|
||||||
setExporting(false);
|
setExporting(false);
|
||||||
}
|
}
|
||||||
@@ -600,7 +624,7 @@ export default function CertificateDetailPage() {
|
|||||||
)}
|
)}
|
||||||
{!isArchived && (
|
{!isArchived && (
|
||||||
<button
|
<button
|
||||||
onClick={() => { if (confirm('Archive this certificate? This cannot be undone.')) archiveMutation.mutate(); }}
|
onClick={() => setConfirmArchive(true)}
|
||||||
disabled={archiveMutation.isPending}
|
disabled={archiveMutation.isPending}
|
||||||
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
className="btn btn-ghost text-xs text-red-400 hover:text-red-300 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
@@ -931,6 +955,23 @@ export default function CertificateDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{/* UX-H2 / UX-H3 closure — archive is the most-irreversible
|
||||||
|
single-cert action. Gate behind a typed-confirmation prompt
|
||||||
|
so the operator cannot fat-finger through the dialog. */}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmArchive}
|
||||||
|
title="Archive this certificate"
|
||||||
|
message={`This action cannot be undone. The certificate (${cert?.common_name || id}) will be moved to the archive bucket and removed from the active inventory. Active deployments + renewal policies referencing it will be skipped.`}
|
||||||
|
confirmLabel="Archive"
|
||||||
|
cancelLabel="Cancel"
|
||||||
|
destructive
|
||||||
|
typedConfirmation="archive"
|
||||||
|
onConfirm={() => {
|
||||||
|
archiveMutation.mutate();
|
||||||
|
setConfirmArchive(false);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmArchive(false)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { Fragment, useState } from 'react';
|
||||||
|
import { Transition } from '@headlessui/react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { useListParams } from '../hooks/useListParams';
|
import { useListParams } from '../hooks/useListParams';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -30,25 +32,35 @@ function CreateCertificateModal({ onClose, onSuccess }: { onClose: () => void; o
|
|||||||
});
|
});
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
|
|
||||||
|
// Phase 2 P-H1 closure: pre-Phase-2 there were 4 duplicate-key pairs
|
||||||
|
// between this modal and the parent CertificatesPage filter bar:
|
||||||
|
// ['profiles'] vs ['profiles-filter']
|
||||||
|
// ['issuers'] vs ['issuers-filter']
|
||||||
|
// ['owners', 'form'] vs ['owners-filter']
|
||||||
|
// ['teams', 'form'] vs ['teams-filter']
|
||||||
|
// TanStack v5 dedupes on serialized queryKey, so the same call shape
|
||||||
|
// shared between modal + filter now hits the cache exactly once.
|
||||||
|
// Both sites now request per_page=100 (was 500/none here, 100 there
|
||||||
|
// — the modal's "500 entries" was over-fetching for a dropdown).
|
||||||
const { data: profilesResp } = useQuery({
|
const { data: profilesResp } = useQuery({
|
||||||
queryKey: ['profiles'],
|
queryKey: ['profiles', { per_page: 100 }],
|
||||||
queryFn: () => getProfiles(),
|
queryFn: () => getProfiles({ per_page: '100' }),
|
||||||
});
|
});
|
||||||
const { data: issuersResp } = useQuery({
|
const { data: issuersResp } = useQuery({
|
||||||
queryKey: ['issuers'],
|
queryKey: ['issuers', { per_page: 100 }],
|
||||||
queryFn: () => getIssuers(),
|
queryFn: () => getIssuers({ per_page: '100' }),
|
||||||
});
|
});
|
||||||
// C-001: owner_id, team_id, and renewal_policy_id are required by the
|
// C-001: owner_id, team_id, and renewal_policy_id are required by the
|
||||||
// server (handler in internal/api/handler/certificates.go) and by OpenAPI.
|
// server (handler in internal/api/handler/certificates.go) and by OpenAPI.
|
||||||
// Load the catalog so the user selects valid FKs instead of typing free-text
|
// Load the catalog so the user selects valid FKs instead of typing free-text
|
||||||
// IDs that would 400 at the server.
|
// IDs that would 400 at the server.
|
||||||
const { data: ownersResp } = useQuery({
|
const { data: ownersResp } = useQuery({
|
||||||
queryKey: ['owners', 'form'],
|
queryKey: ['owners', { per_page: 100 }],
|
||||||
queryFn: () => getOwners({ per_page: '500' }),
|
queryFn: () => getOwners({ per_page: '100' }),
|
||||||
});
|
});
|
||||||
const { data: teamsResp } = useQuery({
|
const { data: teamsResp } = useQuery({
|
||||||
queryKey: ['teams', 'form'],
|
queryKey: ['teams', { per_page: 100 }],
|
||||||
queryFn: () => getTeams({ per_page: '500' }),
|
queryFn: () => getTeams({ per_page: '100' }),
|
||||||
});
|
});
|
||||||
// G-1: swap from getPolicies (compliance rules, pol-*) to getRenewalPolicies
|
// G-1: swap from getPolicies (compliance rules, pol-*) to getRenewalPolicies
|
||||||
// (lifecycle policies, rp-*). managed_certificates.renewal_policy_id FK
|
// (lifecycle policies, rp-*). managed_certificates.renewal_policy_id FK
|
||||||
@@ -467,11 +479,28 @@ export default function CertificatesPage() {
|
|||||||
const [showBulkReassign, setShowBulkReassign] = useState(false);
|
const [showBulkReassign, setShowBulkReassign] = useState(false);
|
||||||
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
|
const [bulkRenewProgress, setBulkRenewProgress] = useState<{ done: number; total: number; running: boolean } | null>(null);
|
||||||
|
|
||||||
const { data: issuersData } = useQuery({ queryKey: ['issuers-filter'], queryFn: () => getIssuers({ per_page: '100' }) });
|
// Phase 2 P-H1 closure: queryKey now matches CreateCertificateModal's
|
||||||
const { data: ownersData } = useQuery({ queryKey: ['owners-filter'], queryFn: () => getOwners({ per_page: '100' }) });
|
// upstream calls byte-for-byte (`[name, { per_page: 100 }]`). TanStack
|
||||||
const { data: profilesData } = useQuery({ queryKey: ['profiles-filter'], queryFn: () => getProfiles({ per_page: '100' }) });
|
// v5 serializes the key on insert + comparison; identical serialization
|
||||||
|
// means the modal + filter share one cache slot. Pre-Phase-2 these
|
||||||
|
// were 4 independent fetches that returned the same data.
|
||||||
|
const { data: issuersData } = useQuery({
|
||||||
|
queryKey: ['issuers', { per_page: 100 }],
|
||||||
|
queryFn: () => getIssuers({ per_page: '100' }),
|
||||||
|
});
|
||||||
|
const { data: ownersData } = useQuery({
|
||||||
|
queryKey: ['owners', { per_page: 100 }],
|
||||||
|
queryFn: () => getOwners({ per_page: '100' }),
|
||||||
|
});
|
||||||
|
const { data: profilesData } = useQuery({
|
||||||
|
queryKey: ['profiles', { per_page: 100 }],
|
||||||
|
queryFn: () => getProfiles({ per_page: '100' }),
|
||||||
|
});
|
||||||
// F-1 closure: hydrate the team filter dropdown.
|
// F-1 closure: hydrate the team filter dropdown.
|
||||||
const { data: teamsFilterData } = useQuery({ queryKey: ['teams-filter'], queryFn: () => getTeams({ per_page: '100' }) });
|
const { data: teamsFilterData } = useQuery({
|
||||||
|
queryKey: ['teams', { per_page: 100 }],
|
||||||
|
queryFn: () => getTeams({ per_page: '100' }),
|
||||||
|
});
|
||||||
|
|
||||||
const params: Record<string, string> = {};
|
const params: Record<string, string> = {};
|
||||||
if (statusFilter) params.status = statusFilter;
|
if (statusFilter) params.status = statusFilter;
|
||||||
@@ -511,9 +540,29 @@ export default function CertificatesPage() {
|
|||||||
total: result.total_matched,
|
total: result.total_matched,
|
||||||
running: false,
|
running: false,
|
||||||
});
|
});
|
||||||
} catch {
|
// UX-L5 closure (Phase 1): post-action toast with a "View jobs"
|
||||||
|
// action that deep-links to the Jobs page filtered to the
|
||||||
|
// certificate IDs we just renewed. The audit's missing
|
||||||
|
// "what just happened" affordance — operators can now jump
|
||||||
|
// straight to the resulting jobs.
|
||||||
|
if (result.total_enqueued > 0) {
|
||||||
|
toast.success(
|
||||||
|
`Triggered renewal for ${result.total_enqueued} certificate${result.total_enqueued > 1 ? 's' : ''}`,
|
||||||
|
{
|
||||||
|
action: {
|
||||||
|
label: `View ${result.total_enqueued} jobs`,
|
||||||
|
onClick: () =>
|
||||||
|
navigate(`/jobs?certificate_ids=${ids.join(',')}`),
|
||||||
|
},
|
||||||
|
duration: 8000,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
// surface as a "0 of N" terminal state — no retries.
|
// surface as a "0 of N" terminal state — no retries.
|
||||||
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
|
setBulkRenewProgress({ done: 0, total: ids.length, running: false });
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
toast.error(`Bulk renewal failed: ${msg}`);
|
||||||
}
|
}
|
||||||
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
queryClient.invalidateQueries({ queryKey: ['certificates'] });
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
@@ -566,8 +615,20 @@ export default function CertificatesPage() {
|
|||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Bulk Action Bar */}
|
{/* Bulk Action Bar — UX-L5 (Phase 1): Headless UI <Transition>
|
||||||
{hasSelection && (
|
wraps the slide-in/out so the bar doesn't snap when selection
|
||||||
|
flips. Transition respects prefers-reduced-motion via the
|
||||||
|
global @media block in index.css. */}
|
||||||
|
<Transition
|
||||||
|
show={hasSelection}
|
||||||
|
as={Fragment}
|
||||||
|
enter="transition-all duration-200 ease-out"
|
||||||
|
enterFrom="opacity-0 -translate-y-2"
|
||||||
|
enterTo="opacity-100 translate-y-0"
|
||||||
|
leave="transition-all duration-150 ease-in"
|
||||||
|
leaveFrom="opacity-100 translate-y-0"
|
||||||
|
leaveTo="opacity-0 -translate-y-2"
|
||||||
|
>
|
||||||
<div className="px-6 py-3 bg-brand-50 border-b border-brand-200 flex items-center justify-between">
|
<div className="px-6 py-3 bg-brand-50 border-b border-brand-200 flex items-center justify-between">
|
||||||
<span className="text-sm text-brand-600 font-medium">{selectedArray.length} selected</span>
|
<span className="text-sm text-brand-600 font-medium">{selectedArray.length} selected</span>
|
||||||
<div className="flex gap-2">
|
<div className="flex gap-2">
|
||||||
@@ -593,7 +654,7 @@ export default function CertificatesPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
</Transition>
|
||||||
|
|
||||||
{/* Bulk Renewal Success */}
|
{/* Bulk Renewal Success */}
|
||||||
{bulkRenewProgress && !bulkRenewProgress.running && (
|
{bulkRenewProgress && !bulkRenewProgress.running && (
|
||||||
|
|||||||
+156
-160
@@ -1,11 +1,8 @@
|
|||||||
import { useState } from 'react';
|
import { Suspense, lazy, useEffect, useMemo, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
|
import { STALE_TIME } from '../api/queryConstants';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import {
|
|
||||||
BarChart, Bar, LineChart, Line, PieChart, Pie, Cell,
|
|
||||||
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend,
|
|
||||||
} from 'recharts';
|
|
||||||
import {
|
import {
|
||||||
getCertificates, getJobs, getHealth,
|
getCertificates, getJobs, getHealth,
|
||||||
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
|
getDashboardSummary, getCertificatesByStatus, getExpirationTimeline,
|
||||||
@@ -13,11 +10,28 @@ import {
|
|||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
|
import Skeleton from '../components/Skeleton';
|
||||||
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
import { daysUntil, expiryColor, formatDate } from '../api/utils';
|
||||||
import OnboardingWizard from './OnboardingWizard';
|
// Phase 4 closure (PERF-M1 + P-H3): memo-wrapped chart panels so a query
|
||||||
|
// refetch in one tile doesn't force every Recharts subtree to reconcile.
|
||||||
|
// See pages/dashboard/charts.tsx for the equality model.
|
||||||
|
import {
|
||||||
|
CertsByStatusPieChart,
|
||||||
|
ExpirationTimelineBarChart,
|
||||||
|
JobTrendsLineChart,
|
||||||
|
IssuanceRateBarChart,
|
||||||
|
type PieDatum,
|
||||||
|
type WeeklyExpirationDatum,
|
||||||
|
} from './dashboard/charts';
|
||||||
|
// Phase 4 closure (FE-M5): OnboardingWizard is 1043 LOC + only renders
|
||||||
|
// on first-run dashboards (one-time dismiss persisted to localStorage).
|
||||||
|
// Lazy-loading the wizard keeps its step-form code off the hot path for
|
||||||
|
// every dashboard load after the operator dismisses it once.
|
||||||
|
const OnboardingWizard = lazy(() => import('./OnboardingWizard'));
|
||||||
|
|
||||||
// Convert PascalCase status like "RenewalInProgress" to "Renewal In Progress"
|
// formatStatus moved to pages/dashboard/charts.tsx in Phase 4 alongside
|
||||||
const formatStatus = (s: string) => s.replace(/([a-z])([A-Z])/g, '$1 $2');
|
// the memoized chart panels that use it; deleted from here in Hotfix #8
|
||||||
|
// to close CodeQL js/unused-local-variable alert #35.
|
||||||
|
|
||||||
const STATUS_COLORS: Record<string, string> = {
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
Active: '#10b981',
|
Active: '#10b981',
|
||||||
@@ -53,30 +67,9 @@ function StatCard({ label, value, icon, color }: { label: string; value: string
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ChartCard({ title, children }: { title: string; children: React.ReactNode }) {
|
// ChartCard + CustomTooltip + formatShortDate moved to
|
||||||
return (
|
// pages/dashboard/charts.tsx (Phase 4 PERF-M1 closure) where they live
|
||||||
<div className="bg-surface border border-surface-border rounded p-5 shadow-sm">
|
// alongside the memo-wrapped chart panels that consume them.
|
||||||
<h3 className="text-sm font-semibold text-ink-muted mb-4">{title}</h3>
|
|
||||||
<div className="h-64">
|
|
||||||
{children}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const CustomTooltip = ({ active, payload, label }: any) => {
|
|
||||||
if (!active || !payload?.length) return null;
|
|
||||||
return (
|
|
||||||
<div className="bg-surface border border-surface-border rounded px-3 py-2 text-xs shadow-lg">
|
|
||||||
<p className="text-ink mb-1">{label}</p>
|
|
||||||
{payload.map((entry: any, i: number) => (
|
|
||||||
<p key={i} style={{ color: entry.color }}>
|
|
||||||
{entry.name}: {typeof entry.value === 'number' && entry.name?.includes('rate') ? `${entry.value.toFixed(1)}%` : entry.value}
|
|
||||||
</p>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
function DigestCard() {
|
function DigestCard() {
|
||||||
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
const [previewHtml, setPreviewHtml] = useState<string | null>(null);
|
||||||
@@ -182,16 +175,117 @@ export default function DashboardPage() {
|
|||||||
// even after dismissal. Takes precedence over localStorage dismissal; stripped on close.
|
// even after dismissal. Takes precedence over localStorage dismissal; stripped on close.
|
||||||
const forceOnboarding = searchParams.get('onboarding') === '1';
|
const forceOnboarding = searchParams.get('onboarding') === '1';
|
||||||
|
|
||||||
|
// Phase 2 PERF-H1 closure: visibility-aware polling.
|
||||||
|
// Pre-Phase-2: Dashboard fired 9 useQuery on mount with 8 polling
|
||||||
|
// (1× 10s + 5× 30s + 2× 60s = ~18 background calls/min). When the
|
||||||
|
// browser tab is hidden (operator working in a different tab) the
|
||||||
|
// polling still fires — wasted backend cycles + battery.
|
||||||
|
//
|
||||||
|
// Fix: track document.visibilityState; when hidden, the
|
||||||
|
// refetchInterval gate below returns false (paused). Also bump the
|
||||||
|
// `jobs` poll from 10s → 30s — the live-tile reason (operator
|
||||||
|
// watching a job finish) doesn't need 10s granularity when 30s is
|
||||||
|
// already inside the human-attention window. The CertificateDetail
|
||||||
|
// page is where 10s polling makes sense (the operator is staring
|
||||||
|
// at the specific job they just kicked off).
|
||||||
|
//
|
||||||
|
// Backend-aggregation gap: ['dashboard-summary'] + ['certs-by-status']
|
||||||
|
// + ['certificates', {}] could collapse into a single endpoint
|
||||||
|
// (3 round-trips → 1) — tracked as a separate Phase-3 backend item.
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [tabVisible, setTabVisible] = useState(
|
||||||
|
typeof document !== 'undefined' ? document.visibilityState === 'visible' : true,
|
||||||
|
);
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof document === 'undefined') return;
|
||||||
|
const handler = () => {
|
||||||
|
const visible = document.visibilityState === 'visible';
|
||||||
|
setTabVisible(visible);
|
||||||
|
// When the tab becomes visible after being hidden, immediately
|
||||||
|
// invalidate the dashboard live-tile queries so the operator
|
||||||
|
// sees fresh data instead of waiting for the next poll tick.
|
||||||
|
if (visible) {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['health'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['dashboard-summary'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['jobs', {}] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['certs-by-status'] });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
document.addEventListener('visibilitychange', handler);
|
||||||
|
return () => document.removeEventListener('visibilitychange', handler);
|
||||||
|
}, [queryClient]);
|
||||||
|
|
||||||
|
// refetchInterval returns false (paused) when the tab is hidden;
|
||||||
|
// otherwise the per-query base interval applies.
|
||||||
|
const liveTileGate = (baseMs: number) => (tabVisible ? baseMs : false);
|
||||||
|
|
||||||
// All hooks must be called unconditionally (React rules of hooks — no hooks after early returns)
|
// All hooks must be called unconditionally (React rules of hooks — no hooks after early returns)
|
||||||
const { data: health } = useQuery({ queryKey: ['health'], queryFn: getHealth, refetchInterval: 30000 });
|
const { data: health } = useQuery({
|
||||||
const { data: summary } = useQuery({ queryKey: ['dashboard-summary'], queryFn: getDashboardSummary, refetchInterval: 30000 });
|
queryKey: ['health'], queryFn: getHealth,
|
||||||
|
refetchInterval: liveTileGate(30_000),
|
||||||
|
refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME,
|
||||||
|
});
|
||||||
|
const { data: summary } = useQuery({
|
||||||
|
queryKey: ['dashboard-summary'], queryFn: getDashboardSummary,
|
||||||
|
refetchInterval: liveTileGate(30_000),
|
||||||
|
refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME,
|
||||||
|
});
|
||||||
const { data: issuersData } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
|
const { data: issuersData } = useQuery({ queryKey: ['issuers'], queryFn: () => getIssuers() });
|
||||||
const { data: statusCounts } = useQuery({ queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus, refetchInterval: 30000 });
|
const { data: statusCounts } = useQuery({
|
||||||
const { data: expirationTimeline } = useQuery({ queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90), refetchInterval: 60000 });
|
queryKey: ['certs-by-status'], queryFn: getCertificatesByStatus,
|
||||||
const { data: jobTrends } = useQuery({ queryKey: ['job-trends'], queryFn: () => getJobTrends(30), refetchInterval: 30000 });
|
refetchInterval: liveTileGate(30_000),
|
||||||
const { data: issuanceRate } = useQuery({ queryKey: ['issuance-rate'], queryFn: () => getIssuanceRate(30), refetchInterval: 60000 });
|
refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME,
|
||||||
const { data: certs } = useQuery({ queryKey: ['certificates', {}], queryFn: () => getCertificates(), refetchInterval: 30000 });
|
});
|
||||||
const { data: jobs } = useQuery({ queryKey: ['jobs', {}], queryFn: () => getJobs(), refetchInterval: 10000 });
|
const { data: expirationTimeline } = useQuery({
|
||||||
|
queryKey: ['expiration-timeline'], queryFn: () => getExpirationTimeline(90),
|
||||||
|
refetchInterval: liveTileGate(60_000),
|
||||||
|
});
|
||||||
|
const { data: jobTrends } = useQuery({
|
||||||
|
queryKey: ['job-trends'], queryFn: () => getJobTrends(30),
|
||||||
|
refetchInterval: liveTileGate(30_000),
|
||||||
|
});
|
||||||
|
const { data: issuanceRate } = useQuery({
|
||||||
|
queryKey: ['issuance-rate'], queryFn: () => getIssuanceRate(30),
|
||||||
|
refetchInterval: liveTileGate(60_000),
|
||||||
|
});
|
||||||
|
const { data: certs } = useQuery({
|
||||||
|
queryKey: ['certificates', {}], queryFn: () => getCertificates(),
|
||||||
|
refetchInterval: liveTileGate(30_000),
|
||||||
|
});
|
||||||
|
const { data: jobs } = useQuery({
|
||||||
|
queryKey: ['jobs', {}], queryFn: () => getJobs(),
|
||||||
|
refetchInterval: liveTileGate(30_000), // PERF-H1: 10s → 30s
|
||||||
|
refetchOnWindowFocus: true, staleTime: STALE_TIME.REAL_TIME,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Prepare pie chart data — memoized so the reference is stable across
|
||||||
|
// re-renders that didn't change statusCounts. Without this useMemo the
|
||||||
|
// chart's React.memo prop-equality check fails on every dashboard
|
||||||
|
// re-render (fresh array every time) and the perf win evaporates.
|
||||||
|
//
|
||||||
|
// Hooks must be called unconditionally on every render path (Rules of
|
||||||
|
// Hooks), so these live BEFORE the wizard early-return below — never
|
||||||
|
// after it.
|
||||||
|
const pieData = useMemo<PieDatum[]>(() => (
|
||||||
|
(statusCounts || []).filter(s => s.count > 0).map(s => ({
|
||||||
|
name: s.status,
|
||||||
|
value: s.count,
|
||||||
|
fill: STATUS_COLORS[s.status] || '#64748b',
|
||||||
|
}))
|
||||||
|
), [statusCounts]);
|
||||||
|
|
||||||
|
// Format expiration heatmap for display — aggregate weekly for 90 days.
|
||||||
|
// Same useMemo reasoning as pieData above.
|
||||||
|
const weeklyExpiration = useMemo<WeeklyExpirationDatum[]>(() => (
|
||||||
|
(expirationTimeline || []).reduce<WeeklyExpirationDatum[]>((acc, bucket, i) => {
|
||||||
|
const weekIdx = Math.floor(i / 7);
|
||||||
|
if (!acc[weekIdx]) {
|
||||||
|
acc[weekIdx] = { week: bucket.date, count: 0 };
|
||||||
|
}
|
||||||
|
acc[weekIdx].count += bucket.count;
|
||||||
|
return acc;
|
||||||
|
}, [])
|
||||||
|
), [expirationTimeline]);
|
||||||
|
|
||||||
// Detect first-run ONCE: no user-configured issuers AND no certificates.
|
// Detect first-run ONCE: no user-configured issuers AND no certificates.
|
||||||
// Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot.
|
// Auto-seeded env var issuers (source="env") don't count — they exist on every fresh boot.
|
||||||
@@ -209,17 +303,19 @@ export default function DashboardPage() {
|
|||||||
|
|
||||||
if ((showWizard && !onboardingDismissed) || forceOnboarding) {
|
if ((showWizard && !onboardingDismissed) || forceOnboarding) {
|
||||||
return (
|
return (
|
||||||
<OnboardingWizard onDismiss={() => {
|
<Suspense fallback={<Skeleton variant="page" ariaLabel="Loading onboarding wizard" />}>
|
||||||
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
<OnboardingWizard onDismiss={() => {
|
||||||
setOnboardingDismissed(true);
|
try { localStorage.setItem('certctl:onboarding-dismissed', 'true'); } catch { /* noop */ }
|
||||||
setShowWizard(false);
|
setOnboardingDismissed(true);
|
||||||
// Strip ?onboarding=1 so page refresh doesn't relaunch the wizard
|
setShowWizard(false);
|
||||||
if (searchParams.has('onboarding')) {
|
// Strip ?onboarding=1 so page refresh doesn't relaunch the wizard
|
||||||
const next = new URLSearchParams(searchParams);
|
if (searchParams.has('onboarding')) {
|
||||||
next.delete('onboarding');
|
const next = new URLSearchParams(searchParams);
|
||||||
setSearchParams(next, { replace: true });
|
next.delete('onboarding');
|
||||||
}
|
setSearchParams(next, { replace: true });
|
||||||
}} />
|
}
|
||||||
|
}} />
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -229,29 +325,6 @@ export default function DashboardPage() {
|
|||||||
const activeAgents = summary?.active_agents || 0;
|
const activeAgents = summary?.active_agents || 0;
|
||||||
const pendingJobs = summary?.pending_jobs || 0;
|
const pendingJobs = summary?.pending_jobs || 0;
|
||||||
|
|
||||||
// Prepare pie chart data
|
|
||||||
const pieData = (statusCounts || []).filter(s => s.count > 0).map(s => ({
|
|
||||||
name: s.status,
|
|
||||||
value: s.count,
|
|
||||||
fill: STATUS_COLORS[s.status] || '#64748b',
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Format expiration heatmap for display — aggregate weekly for 90 days
|
|
||||||
const weeklyExpiration = (expirationTimeline || []).reduce<{ week: string; count: number }[]>((acc, bucket, i) => {
|
|
||||||
const weekIdx = Math.floor(i / 7);
|
|
||||||
if (!acc[weekIdx]) {
|
|
||||||
acc[weekIdx] = { week: bucket.date, count: 0 };
|
|
||||||
}
|
|
||||||
acc[weekIdx].count += bucket.count;
|
|
||||||
return acc;
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Format dates for x-axis labels
|
|
||||||
const formatShortDate = (dateStr: string) => {
|
|
||||||
const d = new Date(dateStr + 'T00:00:00');
|
|
||||||
return `${d.getMonth() + 1}/${d.getDate()}`;
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -273,96 +346,19 @@ export default function DashboardPage() {
|
|||||||
icon="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
icon="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row 1 */}
|
{/* Charts Row 1 — memo-wrapped panels from pages/dashboard/charts.tsx
|
||||||
|
(Phase 4 PERF-M1). Each panel re-renders only when its own data
|
||||||
|
ref changes, so a refetch on one tile doesn't reconcile the
|
||||||
|
other three Recharts subtrees. */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Certificates by Status (Pie) */}
|
<CertsByStatusPieChart data={pieData} />
|
||||||
<ChartCard title="Certificates by Status">
|
<ExpirationTimelineBarChart data={weeklyExpiration} />
|
||||||
{pieData.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<PieChart>
|
|
||||||
<Pie
|
|
||||||
data={pieData}
|
|
||||||
cx="50%"
|
|
||||||
cy="50%"
|
|
||||||
innerRadius={60}
|
|
||||||
outerRadius={90}
|
|
||||||
paddingAngle={2}
|
|
||||||
dataKey="value"
|
|
||||||
label={({ name, value }) => `${formatStatus(name || '')}: ${value}`}
|
|
||||||
labelLine={false}
|
|
||||||
>
|
|
||||||
{pieData.map((entry, index) => (
|
|
||||||
<Cell key={index} fill={entry.fill} />
|
|
||||||
))}
|
|
||||||
</Pie>
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend
|
|
||||||
verticalAlign="bottom"
|
|
||||||
height={36}
|
|
||||||
formatter={(value: string) => <span className="text-xs text-ink-muted">{formatStatus(value)}</span>}
|
|
||||||
/>
|
|
||||||
</PieChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No certificate data</div>
|
|
||||||
)}
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
{/* Expiration Heatmap (Bar chart by week) */}
|
|
||||||
<ChartCard title="Expiration Timeline (Next 90 Days)">
|
|
||||||
{weeklyExpiration.length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={weeklyExpiration}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
||||||
<XAxis dataKey="week" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
|
||||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar dataKey="count" name="Expiring certs" fill="#f59e0b" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No expiration data</div>
|
|
||||||
)}
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Charts Row 2 */}
|
{/* Charts Row 2 */}
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
{/* Job Trends (Line chart) */}
|
<JobTrendsLineChart data={jobTrends || []} />
|
||||||
<ChartCard title="Job Success/Failure Trends (30 Days)">
|
<IssuanceRateBarChart data={issuanceRate || []} />
|
||||||
{(jobTrends || []).length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<LineChart data={jobTrends}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
||||||
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
|
||||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Legend formatter={(value: string) => <span className="text-xs text-ink-muted">{value}</span>} />
|
|
||||||
<Line type="monotone" dataKey="completed_count" name="Completed" stroke="#10b981" strokeWidth={2} dot={false} />
|
|
||||||
<Line type="monotone" dataKey="failed_count" name="Failed" stroke="#ef4444" strokeWidth={2} dot={false} />
|
|
||||||
</LineChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No job trend data</div>
|
|
||||||
)}
|
|
||||||
</ChartCard>
|
|
||||||
|
|
||||||
{/* Issuance Rate (Bar chart) */}
|
|
||||||
<ChartCard title="Certificate Issuance Rate (30 Days)">
|
|
||||||
{(issuanceRate || []).length > 0 ? (
|
|
||||||
<ResponsiveContainer width="100%" height="100%">
|
|
||||||
<BarChart data={issuanceRate}>
|
|
||||||
<CartesianGrid strokeDasharray="3 3" stroke="#e2e8f0" />
|
|
||||||
<XAxis dataKey="date" tick={{ fill: '#64748b', fontSize: 11 }} tickFormatter={formatShortDate} />
|
|
||||||
<YAxis tick={{ fill: '#64748b', fontSize: 11 }} allowDecimals={false} />
|
|
||||||
<Tooltip content={<CustomTooltip />} />
|
|
||||||
<Bar dataKey="issued_count" name="Issued" fill="#2ea88f" radius={[4, 4, 0, 0]} />
|
|
||||||
</BarChart>
|
|
||||||
</ResponsiveContainer>
|
|
||||||
) : (
|
|
||||||
<div className="h-full flex items-center justify-center text-sm text-ink-faint">No issuance data</div>
|
|
||||||
)}
|
|
||||||
</ChartCard>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import {
|
import {
|
||||||
getDiscoveredCertificates,
|
getDiscoveredCertificates,
|
||||||
@@ -138,18 +139,60 @@ export default function DiscoveryPage() {
|
|||||||
queryFn: () => getAgents({ per_page: '200' }),
|
queryFn: () => getAgents({ per_page: '200' }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const claimMutation = useTrackedMutation({
|
// Phase 2 TQ-M3 closure: claim + dismiss with optimistic updates.
|
||||||
mutationFn: ({ id, managedCertId }: { id: string; managedCertId: string }) =>
|
// Each one flips the row's status in the ['discovered-certificates']
|
||||||
|
// cache immediately so the visual response is sub-100ms regardless
|
||||||
|
// of network RTT. Rollback restores the snapshot + fires a Sonner
|
||||||
|
// error toast.
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
type DiscSnapshot = {
|
||||||
|
prev?: { data: DiscoveredCertificate[]; total: number } | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
const claimMutation = useTrackedMutation<unknown, Error, { id: string; managedCertId: string }, DiscSnapshot>({
|
||||||
|
mutationFn: ({ id, managedCertId }) =>
|
||||||
claimDiscoveredCertificate(id, managedCertId),
|
claimDiscoveredCertificate(id, managedCertId),
|
||||||
invalidates: [['discovered-certificates'], ['discovery-summary']],
|
invalidates: [['discovered-certificates'], ['discovery-summary']],
|
||||||
|
onMutate: async ({ id }): Promise<DiscSnapshot> => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['discovered-certificates'] });
|
||||||
|
const prev = queryClient.getQueryData(['discovered-certificates']) as DiscSnapshot['prev'];
|
||||||
|
if (prev) {
|
||||||
|
queryClient.setQueryData(['discovered-certificates'], {
|
||||||
|
...prev,
|
||||||
|
data: prev.data.map((c) => (c.id === id ? { ...c, status: 'Managed' as const } : c)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (err, _vars, snap) => {
|
||||||
|
if (snap?.prev) queryClient.setQueryData(['discovered-certificates'], snap.prev);
|
||||||
|
toast.error(`Claim failed: ${err.message}`);
|
||||||
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
|
toast.success('Certificate claimed');
|
||||||
setClaimingCert(null);
|
setClaimingCert(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const dismissMutation = useTrackedMutation({
|
const dismissMutation = useTrackedMutation<unknown, Error, string, DiscSnapshot>({
|
||||||
mutationFn: dismissDiscoveredCertificate,
|
mutationFn: dismissDiscoveredCertificate,
|
||||||
invalidates: [['discovered-certificates'], ['discovery-summary']],
|
invalidates: [['discovered-certificates'], ['discovery-summary']],
|
||||||
|
onMutate: async (id): Promise<DiscSnapshot> => {
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['discovered-certificates'] });
|
||||||
|
const prev = queryClient.getQueryData(['discovered-certificates']) as DiscSnapshot['prev'];
|
||||||
|
if (prev) {
|
||||||
|
queryClient.setQueryData(['discovered-certificates'], {
|
||||||
|
...prev,
|
||||||
|
data: prev.data.map((c) => (c.id === id ? { ...c, status: 'Dismissed' as const } : c)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return { prev };
|
||||||
|
},
|
||||||
|
onError: (err, _id, snap) => {
|
||||||
|
if (snap?.prev) queryClient.setQueryData(['discovered-certificates'], snap.prev);
|
||||||
|
toast.error(`Dismiss failed: ${err.message}`);
|
||||||
|
},
|
||||||
|
onSuccess: () => toast.success('Discovery dismissed'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const formatExpiry = (notAfter?: string) => {
|
const formatExpiry = (notAfter?: string) => {
|
||||||
@@ -195,7 +238,7 @@ export default function DiscoveryPage() {
|
|||||||
const badge = sourceTypeBadge(c.agent_id);
|
const badge = sourceTypeBadge(c.agent_id);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<span className={`inline-block px-1.5 py-0.5 rounded text-[10px] font-medium ${badge.style} mr-1`}>{badge.label}</span>
|
<span className={`inline-block px-1.5 py-0.5 rounded text-2xs font-medium ${badge.style} mr-1`}>{badge.label}</span>
|
||||||
<div className="text-xs text-ink-faint truncate max-w-[180px] mt-0.5" title={c.source_path}>{c.source_path}</div>
|
<div className="text-xs text-ink-faint truncate max-w-[180px] mt-0.5" title={c.source_path}>{c.source_path}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -218,7 +261,7 @@ export default function DiscoveryPage() {
|
|||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<span className="text-xs text-ink-muted">{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}</span>
|
<span className="text-xs text-ink-muted">{c.key_algorithm}{c.key_size ? ` ${c.key_size}` : ''}</span>
|
||||||
{c.is_ca && (
|
{c.is_ca && (
|
||||||
<span className="text-[10px] px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">CA</span>
|
<span className="text-2xs px-1.5 py-0.5 rounded bg-purple-100 text-purple-700 font-medium">CA</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
@@ -226,7 +269,7 @@ export default function DiscoveryPage() {
|
|||||||
{
|
{
|
||||||
key: 'fingerprint',
|
key: 'fingerprint',
|
||||||
label: 'Fingerprint',
|
label: 'Fingerprint',
|
||||||
render: (c) => <span className="font-mono text-[10px] text-ink-faint">{c.fingerprint_sha256?.substring(0, 16)}...</span>,
|
render: (c) => <span className="font-mono text-2xs text-ink-faint">{c.fingerprint_sha256?.substring(0, 16)}...</span>,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'actions',
|
key: 'actions',
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
getAuditEvents,
|
getAuditEvents,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import ModalDialog from '../components/ModalDialog';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
import { useAuth } from '../components/AuthProvider';
|
import { useAuth } from '../components/AuthProvider';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
@@ -216,13 +217,13 @@ function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProp
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3" data-testid={`est-profile-badges-${profile.path_id}`}>
|
<div className="flex flex-wrap gap-2 mb-3" data-testid={`est-profile-badges-${profile.path_id}`}>
|
||||||
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
|
<span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
|
||||||
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
|
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.basic_auth_configured)}`}>
|
<span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.basic_auth_configured)}`}>
|
||||||
HTTP Basic {profile.basic_auth_configured ? 'configured' : 'not set'}
|
HTTP Basic {profile.basic_auth_configured ? 'configured' : 'not set'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.server_keygen_enabled)}`}>
|
<span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.server_keygen_enabled)}`}>
|
||||||
Server-keygen {profile.server_keygen_enabled ? 'enabled' : 'disabled'}
|
Server-keygen {profile.server_keygen_enabled ? 'enabled' : 'disabled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -233,7 +234,7 @@ function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProp
|
|||||||
const value = profile.counters?.[label] ?? 0;
|
const value = profile.counters?.[label] ?? 0;
|
||||||
return (
|
return (
|
||||||
<div key={label} className="bg-surface-alt rounded px-3 py-2" data-testid={`est-counter-${profile.path_id}-${label}`}>
|
<div key={label} className="bg-surface-alt rounded px-3 py-2" data-testid={`est-counter-${profile.path_id}-${label}`}>
|
||||||
<div className="text-[10px] uppercase tracking-wide text-ink-muted">{presentation.label}</div>
|
<div className="text-2xs uppercase tracking-wide text-ink-muted">{presentation.label}</div>
|
||||||
<div className={`text-base font-semibold ${TONE_CLASS[presentation.tone]}`}>{value}</div>
|
<div className={`text-base font-semibold ${TONE_CLASS[presentation.tone]}`}>{value}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -241,7 +242,7 @@ function ProfileSummaryCard({ profile, onRequestReload }: ProfileSummaryCardProp
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{profile.mtls_enabled && profile.trust_anchor_path && (
|
{profile.mtls_enabled && profile.trust_anchor_path && (
|
||||||
<p className="text-[11px] text-ink-muted font-mono mb-2">
|
<p className="text-xs text-ink-muted font-mono mb-2">
|
||||||
Trust bundle: {profile.trust_anchor_path}
|
Trust bundle: {profile.trust_anchor_path}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -276,30 +277,18 @@ interface ConfirmReloadModalProps {
|
|||||||
|
|
||||||
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
||||||
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
|
const pathLabel = profile.path_id || '(legacy /.well-known/est root)';
|
||||||
|
// Phase 5 closure (FE-H3): swapped the inline-managed <div role="dialog">
|
||||||
|
// for ModalDialog (Headless UI) — focus trap + ESC-to-close + backdrop-
|
||||||
|
// click-to-close come for free. Existing test data-testids preserved
|
||||||
|
// verbatim so est-reload-cancel / est-reload-confirm / est-reload-error
|
||||||
|
// assertions keep working.
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalDialog
|
||||||
role="dialog"
|
open={true}
|
||||||
aria-labelledby="est-reload-trust-title"
|
title="Reload EST mTLS trust anchor"
|
||||||
aria-modal="true"
|
onClose={pending ? () => {} : onCancel}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
footer={
|
||||||
>
|
<>
|
||||||
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
|
|
||||||
<h3 id="est-reload-trust-title" className="text-base font-semibold text-ink mb-2">
|
|
||||||
Reload EST mTLS trust anchor
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-ink-muted mb-4">
|
|
||||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
|
||||||
swaps the trust pool for EST profile <strong>{pathLabel}</strong>. Equivalent to sending
|
|
||||||
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
|
||||||
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
|
||||||
fix the file.
|
|
||||||
</p>
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="est-reload-error">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -318,9 +307,22 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
|
|||||||
>
|
>
|
||||||
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-ink-muted mb-3">
|
||||||
|
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||||
|
swaps the trust pool for EST profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||||
|
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
||||||
|
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
||||||
|
fix the file.
|
||||||
|
</p>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800" data-testid="est-reload-error">
|
||||||
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</ModalDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -382,7 +382,7 @@ function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
|
|||||||
<span>{formatDateTime(result.probed_at)} · {result.probe_duration_ms}ms</span>
|
<span>{formatDateTime(result.probed_at)} · {result.probe_duration_ms}ms</span>
|
||||||
</div>
|
</div>
|
||||||
{result.error && (
|
{result.error && (
|
||||||
<p className="font-mono text-[11px] mb-2">Error: {result.error}</p>
|
<p className="font-mono text-xs mb-2">Error: {result.error}</p>
|
||||||
)}
|
)}
|
||||||
{result.reachable && (
|
{result.reachable && (
|
||||||
<>
|
<>
|
||||||
@@ -397,9 +397,9 @@ function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
|
|||||||
{result.ca_cert_subject && (
|
{result.ca_cert_subject && (
|
||||||
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
<dl className="grid grid-cols-2 gap-x-3 gap-y-1 mt-2">
|
||||||
<dt className="font-semibold">CA cert subject:</dt>
|
<dt className="font-semibold">CA cert subject:</dt>
|
||||||
<dd className="font-mono text-[11px]">{result.ca_cert_subject}</dd>
|
<dd className="font-mono text-xs">{result.ca_cert_subject}</dd>
|
||||||
<dt className="font-semibold">Issuer:</dt>
|
<dt className="font-semibold">Issuer:</dt>
|
||||||
<dd className="font-mono text-[11px]">{result.ca_cert_issuer}</dd>
|
<dd className="font-mono text-xs">{result.ca_cert_issuer}</dd>
|
||||||
<dt className="font-semibold">Algorithm:</dt>
|
<dt className="font-semibold">Algorithm:</dt>
|
||||||
<dd>{result.ca_cert_algorithm || '(unknown)'}</dd>
|
<dd>{result.ca_cert_algorithm || '(unknown)'}</dd>
|
||||||
<dt className="font-semibold">Chain length:</dt>
|
<dt className="font-semibold">Chain length:</dt>
|
||||||
@@ -417,7 +417,7 @@ function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
|
|||||||
</dl>
|
</dl>
|
||||||
)}
|
)}
|
||||||
{result.advertised_caps && result.advertised_caps.length > 0 && (
|
{result.advertised_caps && result.advertised_caps.length > 0 && (
|
||||||
<p className="mt-2 text-[11px]">
|
<p className="mt-2 text-xs">
|
||||||
Raw caps: <code>{result.advertised_caps.join(', ')}</code>
|
Raw caps: <code>{result.advertised_caps.join(', ')}</code>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
@@ -430,7 +430,7 @@ function SCEPProbeResultPanel({ result }: { result: SCEPProbeResult }) {
|
|||||||
function CapBadge({ label, supported }: { label: string; supported: boolean }) {
|
function CapBadge({ label, supported }: { label: string; supported: boolean }) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
className={`text-[11px] uppercase px-2 py-0.5 rounded border ${
|
className={`text-xs uppercase px-2 py-0.5 rounded border ${
|
||||||
supported ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-gray-100 text-gray-600 border-gray-300'
|
supported ? 'bg-emerald-100 text-emerald-800 border-emerald-300' : 'bg-gray-100 text-gray-600 border-gray-300'
|
||||||
}`}
|
}`}
|
||||||
data-testid={`scep-probe-cap-${label.toLowerCase().replace(/\W/g, '-')}`}
|
data-testid={`scep-probe-cap-${label.toLowerCase().replace(/\W/g, '-')}`}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState, useMemo } from 'react';
|
import { useState, useMemo } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getNotifications, markNotificationRead, requeueNotification } from '../api/client';
|
import { getNotifications, markNotificationRead, requeueNotification } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -8,6 +9,14 @@ import ErrorState from '../components/ErrorState';
|
|||||||
import { timeAgo } from '../api/utils';
|
import { timeAgo } from '../api/utils';
|
||||||
import type { Notification } from '../api/types';
|
import type { Notification } from '../api/types';
|
||||||
|
|
||||||
|
// Phase 2 TQ-M3 closure: optimistic-update context shape. onMutate
|
||||||
|
// snapshots the current ['notifications', tab] cache; onError uses
|
||||||
|
// it to roll back. onSettled fires the invalidation regardless.
|
||||||
|
interface NotifSnapshot {
|
||||||
|
prevAll?: { data: Notification[]; total: number } | undefined;
|
||||||
|
prevDead?: { data: Notification[]; total: number } | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
type ViewMode = 'list' | 'grouped';
|
type ViewMode = 'list' | 'grouped';
|
||||||
|
|
||||||
// I-005: the Notifications page now hosts two tabs. "all" is the pre-I-005
|
// I-005: the Notifications page now hosts two tabs. "all" is the pre-I-005
|
||||||
@@ -43,9 +52,37 @@ export default function NotificationsPage() {
|
|||||||
refetchInterval: 30000,
|
refetchInterval: 30000,
|
||||||
});
|
});
|
||||||
|
|
||||||
const markRead = useTrackedMutation({
|
// Phase 2 TQ-M3 closure: mark-notification-read with optimistic
|
||||||
|
// update. Flip the row's status to 'read' in the cache immediately;
|
||||||
|
// on error, restore the snapshot + show the toast. The success
|
||||||
|
// toast is omitted (the visual flip from unread → read is its own
|
||||||
|
// feedback); errors get a toast because they re-render the row
|
||||||
|
// back to unread and the operator needs to know why.
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const markRead = useTrackedMutation<unknown, Error, string, NotifSnapshot>({
|
||||||
mutationFn: markNotificationRead,
|
mutationFn: markNotificationRead,
|
||||||
invalidates: [['notifications']],
|
invalidates: [['notifications']],
|
||||||
|
onMutate: async (id: string): Promise<NotifSnapshot> => {
|
||||||
|
// Cancel any in-flight refetch so optimistic data doesn't get
|
||||||
|
// overwritten by a stale response landing during the mutation.
|
||||||
|
await queryClient.cancelQueries({ queryKey: ['notifications'] });
|
||||||
|
const snapshot: NotifSnapshot = {
|
||||||
|
prevAll: queryClient.getQueryData(['notifications', 'all']) as NotifSnapshot['prevAll'],
|
||||||
|
prevDead: queryClient.getQueryData(['notifications', 'dead']) as NotifSnapshot['prevDead'],
|
||||||
|
};
|
||||||
|
const flipStatus = (page?: { data: Notification[]; total: number }) =>
|
||||||
|
page
|
||||||
|
? { ...page, data: page.data.map((n) => (n.id === id ? { ...n, status: 'read' as const } : n)) }
|
||||||
|
: page;
|
||||||
|
queryClient.setQueryData(['notifications', 'all'], flipStatus(snapshot.prevAll));
|
||||||
|
queryClient.setQueryData(['notifications', 'dead'], flipStatus(snapshot.prevDead));
|
||||||
|
return snapshot;
|
||||||
|
},
|
||||||
|
onError: (err, _id, snapshot) => {
|
||||||
|
if (snapshot?.prevAll) queryClient.setQueryData(['notifications', 'all'], snapshot.prevAll);
|
||||||
|
if (snapshot?.prevDead) queryClient.setQueryData(['notifications', 'dead'], snapshot.prevDead);
|
||||||
|
toast.error(`Mark-read failed: ${err.message}`);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// I-005: requeue a dead notification. Invalidates both tab cache entries
|
// I-005: requeue a dead notification. Invalidates both tab cache entries
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
import { getMetrics, getPrometheusMetrics, getHealth } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import Timestamp from '../components/Timestamp';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
|
||||||
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
function MetricCard({ label, value, sub }: { label: string; value: string | number; sub?: string }) {
|
||||||
@@ -67,7 +68,7 @@ export default function ObservabilityPage() {
|
|||||||
</span>
|
</span>
|
||||||
{metrics && (
|
{metrics && (
|
||||||
<span className="text-xs text-ink-faint ml-auto">
|
<span className="text-xs text-ink-faint ml-auto">
|
||||||
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: {new Date(metrics.uptime.server_started).toLocaleString()}
|
Uptime: {formatUptime(metrics.uptime.uptime_seconds)} | Started: <Timestamp iso={metrics.uptime.server_started} />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,11 +1,13 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
|
import { getOwners, getTeams, deleteOwner, createOwner, updateOwner } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
import DataTable from '../components/DataTable';
|
import DataTable from '../components/DataTable';
|
||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Owner, Team } from '../api/types';
|
import type { Owner, Team } from '../api/types';
|
||||||
|
|
||||||
@@ -211,10 +213,13 @@ export default function OwnersPage() {
|
|||||||
queryFn: () => getTeams(),
|
queryFn: () => getTeams(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Owner | null>(null);
|
||||||
|
|
||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteOwner,
|
mutationFn: deleteOwner,
|
||||||
invalidates: [['owners']],
|
invalidates: [['owners']],
|
||||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
onSuccess: () => toast.success('Owner deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useTrackedMutation({
|
const createMutation = useTrackedMutation({
|
||||||
@@ -279,7 +284,7 @@ export default function OwnersPage() {
|
|||||||
Edit
|
Edit
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete owner ${o.name}?`)) deleteMutation.mutate(o.id); }}
|
onClick={(e) => { e.stopPropagation(); setConfirmDelete(o); }}
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -329,6 +334,22 @@ export default function OwnersPage() {
|
|||||||
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
error={updateMutation.error ? (updateMutation.error as Error).message : null}
|
||||||
teamsData={teamsData}
|
teamsData={teamsData}
|
||||||
/>
|
/>
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete !== null}
|
||||||
|
title="Delete owner"
|
||||||
|
message={
|
||||||
|
confirmDelete
|
||||||
|
? `Delete owner ${confirmDelete.name}? This action cannot be undone.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => {
|
||||||
|
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import {
|
import {
|
||||||
getRenewalPolicies,
|
getRenewalPolicies,
|
||||||
@@ -206,7 +207,8 @@ export default function RenewalPoliciesPage() {
|
|||||||
// alert so the operator sees "this policy is still attached to N
|
// alert so the operator sees "this policy is still attached to N
|
||||||
// certificates" and can re-target those certs to another policy
|
// certificates" and can re-target those certs to another policy
|
||||||
// before deleting.
|
// before deleting.
|
||||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
onSuccess: () => toast.success('Renewal policy deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: Column<RenewalPolicy>[] = [
|
const columns: Column<RenewalPolicy>[] = [
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
getAuditEvents,
|
getAuditEvents,
|
||||||
} from '../api/client';
|
} from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
|
import ModalDialog from '../components/ModalDialog';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
import { useAuth } from '../components/AuthProvider';
|
import { useAuth } from '../components/AuthProvider';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
@@ -207,13 +208,13 @@ function ProfileSummaryCard({ profile, onViewIntuneDetails }: ProfileSummaryCard
|
|||||||
</header>
|
</header>
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-3" data-testid={`profile-badges-${profile.path_id}`}>
|
<div className="flex flex-wrap gap-2 mb-3" data-testid={`profile-badges-${profile.path_id}`}>
|
||||||
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.challenge_password_set)}`}>
|
<span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.challenge_password_set)}`}>
|
||||||
Challenge password{profile.challenge_password_set ? ' set' : ' MISSING'}
|
Challenge password{profile.challenge_password_set ? ' set' : ' MISSING'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
|
<span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(profile.mtls_enabled)}`}>
|
||||||
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
|
mTLS {profile.mtls_enabled ? 'enabled' : 'disabled'}
|
||||||
</span>
|
</span>
|
||||||
<span className={`text-[11px] uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(intuneEnabled)}`}>
|
<span className={`text-xs uppercase tracking-wide px-2 py-0.5 rounded border ${pillClass(intuneEnabled)}`}>
|
||||||
Intune {intuneEnabled ? 'enabled' : 'disabled'}
|
Intune {intuneEnabled ? 'enabled' : 'disabled'}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -221,7 +222,7 @@ function ProfileSummaryCard({ profile, onViewIntuneDetails }: ProfileSummaryCard
|
|||||||
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-ink-muted">
|
<dl className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs text-ink-muted">
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-semibold text-ink">RA cert subject</dt>
|
<dt className="font-semibold text-ink">RA cert subject</dt>
|
||||||
<dd className="font-mono text-[11px]">{profile.ra_cert_subject || '(not loaded)'}</dd>
|
<dd className="font-mono text-xs">{profile.ra_cert_subject || '(not loaded)'}</dd>
|
||||||
</div>
|
</div>
|
||||||
{profile.ra_cert_not_after && (
|
{profile.ra_cert_not_after && (
|
||||||
<div>
|
<div>
|
||||||
@@ -232,7 +233,7 @@ function ProfileSummaryCard({ profile, onViewIntuneDetails }: ProfileSummaryCard
|
|||||||
{profile.mtls_enabled && profile.mtls_trust_bundle_path && (
|
{profile.mtls_enabled && profile.mtls_trust_bundle_path && (
|
||||||
<div>
|
<div>
|
||||||
<dt className="font-semibold text-ink">mTLS trust bundle</dt>
|
<dt className="font-semibold text-ink">mTLS trust bundle</dt>
|
||||||
<dd className="font-mono text-[11px]">{profile.mtls_trust_bundle_path}</dd>
|
<dd className="font-mono text-xs">{profile.mtls_trust_bundle_path}</dd>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
@@ -267,30 +268,19 @@ interface ConfirmReloadModalProps {
|
|||||||
|
|
||||||
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessage }: ConfirmReloadModalProps) {
|
||||||
const pathLabel = profile.path_id || '(legacy /scep root)';
|
const pathLabel = profile.path_id || '(legacy /scep root)';
|
||||||
|
// Phase 5 closure (FE-H3): swapped the inline-managed <div role="dialog">
|
||||||
|
// for ModalDialog (Headless UI) so the operator gets focus trap, ESC-to-
|
||||||
|
// close, and backdrop-click-to-close. Pre-Phase-5 the modal had aria
|
||||||
|
// attrs but no focus management — Tab would escape out of the panel into
|
||||||
|
// the underlying page, and ESC did nothing. ModalDialog wires both to
|
||||||
|
// onCancel automatically.
|
||||||
return (
|
return (
|
||||||
<div
|
<ModalDialog
|
||||||
role="dialog"
|
open={true}
|
||||||
aria-labelledby="reload-trust-title"
|
title="Reload Intune trust anchor"
|
||||||
aria-modal="true"
|
onClose={pending ? () => {} : onCancel}
|
||||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/40"
|
footer={
|
||||||
>
|
<>
|
||||||
<div className="bg-surface w-full max-w-md rounded-lg shadow-xl border border-surface-border p-6">
|
|
||||||
<h3 id="reload-trust-title" className="text-base font-semibold text-ink mb-2">
|
|
||||||
Reload Intune trust anchor
|
|
||||||
</h3>
|
|
||||||
<p className="text-sm text-ink-muted mb-4">
|
|
||||||
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
|
||||||
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
|
|
||||||
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
|
||||||
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
|
||||||
fix the file.
|
|
||||||
</p>
|
|
||||||
{errorMessage && (
|
|
||||||
<div className="mb-3 rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
|
||||||
{errorMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="flex justify-end gap-2">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onCancel}
|
onClick={onCancel}
|
||||||
@@ -307,9 +297,22 @@ function ConfirmReloadModal({ profile, onCancel, onConfirm, pending, errorMessag
|
|||||||
>
|
>
|
||||||
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
{pending ? 'Reloading…' : 'Reload trust anchor'}
|
||||||
</button>
|
</button>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<p className="text-sm text-ink-muted mb-3">
|
||||||
|
This re-reads <code className="text-xs">{profile.trust_anchor_path}</code> from disk and atomically
|
||||||
|
swaps the trust pool for SCEP profile <strong>{pathLabel}</strong>. Equivalent to sending
|
||||||
|
<code className="text-xs"> SIGHUP </code> to the server. If the new file fails to parse, the
|
||||||
|
previous trust pool stays in place — enrollments keep working off the old trust anchor while you
|
||||||
|
fix the file.
|
||||||
|
</p>
|
||||||
|
{errorMessage && (
|
||||||
|
<div className="rounded border border-red-300 bg-red-50 p-3 text-xs text-red-800">
|
||||||
|
{errorMessage}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</ModalDialog>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,7 +419,7 @@ function IntuneProfileCard({ profile, onRequestReload, highlighted }: IntuneProf
|
|||||||
<div className={`text-lg font-semibold ${TONE_CLASS[presentation.tone]}`} data-testid={`counter-${profile.path_id}-${label}`}>
|
<div className={`text-lg font-semibold ${TONE_CLASS[presentation.tone]}`} data-testid={`counter-${profile.path_id}-${label}`}>
|
||||||
{value}
|
{value}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-[11px] text-ink-muted uppercase tracking-wide">{presentation.label}</div>
|
<div className="text-xs text-ink-muted uppercase tracking-wide">{presentation.label}</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
@@ -442,7 +445,7 @@ function IntuneProfileCard({ profile, onRequestReload, highlighted }: IntuneProf
|
|||||||
<summary className="cursor-pointer font-semibold text-ink">Trust anchor details</summary>
|
<summary className="cursor-pointer font-semibold text-ink">Trust anchor details</summary>
|
||||||
<table className="mt-2 w-full text-left">
|
<table className="mt-2 w-full text-left">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="text-[11px] text-ink-muted uppercase">
|
<tr className="text-xs text-ink-muted uppercase">
|
||||||
<th className="py-1 pr-2">Subject</th>
|
<th className="py-1 pr-2">Subject</th>
|
||||||
<th className="py-1 pr-2">Not after</th>
|
<th className="py-1 pr-2">Not after</th>
|
||||||
<th className="py-1">Days to expiry</th>
|
<th className="py-1">Days to expiry</th>
|
||||||
|
|||||||
@@ -93,23 +93,37 @@ describe('TargetsPage — T-1 page coverage', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('Delete confirm flow calls deleteTarget(id)', async () => {
|
it('Delete confirm flow calls deleteTarget(id)', async () => {
|
||||||
const origConfirm = globalThis.confirm;
|
// Phase 1 UX-H2 closure: Delete now opens a ConfirmDialog primitive
|
||||||
globalThis.confirm = vi.fn(() => true);
|
// (Headless UI) rather than firing window.confirm(). The new flow:
|
||||||
try {
|
// 1. operator clicks the row's "Delete" button → sets state
|
||||||
renderWithQuery(<TargetsPage />);
|
// that opens the dialog
|
||||||
await waitFor(() => {
|
// 2. ConfirmDialog mounts with title "Delete deployment target"
|
||||||
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
|
// 3. operator clicks the dialog's destructive "Delete" button
|
||||||
});
|
// 4. deleteTarget(id) fires
|
||||||
|
renderWithQuery(<TargetsPage />);
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(screen.getByText('IIS Web01')).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
const deleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
const rowDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
||||||
fireEvent.click(deleteButtons[0]!);
|
fireEvent.click(rowDeleteButtons[0]!);
|
||||||
|
|
||||||
await waitFor(() => {
|
// Wait for the dialog title to mount (Headless UI Transition).
|
||||||
expect(client.deleteTarget).toHaveBeenCalled();
|
await waitFor(() => {
|
||||||
});
|
expect(screen.getByText('Delete deployment target')).toBeInTheDocument();
|
||||||
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
|
});
|
||||||
} finally {
|
|
||||||
globalThis.confirm = origConfirm;
|
// Click the dialog's destructive-styled confirm button (.btn-danger).
|
||||||
}
|
const allDeleteButtons = await screen.findAllByRole('button', { name: 'Delete' });
|
||||||
|
const dialogDeleteBtn = allDeleteButtons.find((b) =>
|
||||||
|
b.className.includes('btn-danger'),
|
||||||
|
);
|
||||||
|
expect(dialogDeleteBtn).toBeDefined();
|
||||||
|
fireEvent.click(dialogDeleteBtn!);
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(client.deleteTarget).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
expect(vi.mocked(client.deleteTarget).mock.calls[0]?.[0]).toBe('tgt-iis-prod');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
|
import { getTargets, createTarget, deleteTarget, getAgents } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -8,6 +9,7 @@ import DataTable from '../components/DataTable';
|
|||||||
import type { Column } from '../components/DataTable';
|
import type { Column } from '../components/DataTable';
|
||||||
import StatusBadge from '../components/StatusBadge';
|
import StatusBadge from '../components/StatusBadge';
|
||||||
import ErrorState from '../components/ErrorState';
|
import ErrorState from '../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import { formatDateTime } from '../api/utils';
|
import { formatDateTime } from '../api/utils';
|
||||||
import type { Target } from '../api/types';
|
import type { Target } from '../api/types';
|
||||||
|
|
||||||
@@ -403,6 +405,7 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc
|
|||||||
export default function TargetsPage() {
|
export default function TargetsPage() {
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [showCreate, setShowCreate] = useState(false);
|
const [showCreate, setShowCreate] = useState(false);
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState<Target | null>(null);
|
||||||
|
|
||||||
const { data, isLoading, error, refetch } = useQuery({
|
const { data, isLoading, error, refetch } = useQuery({
|
||||||
queryKey: ['targets'],
|
queryKey: ['targets'],
|
||||||
@@ -412,6 +415,8 @@ export default function TargetsPage() {
|
|||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteTarget,
|
mutationFn: deleteTarget,
|
||||||
invalidates: [['targets']],
|
invalidates: [['targets']],
|
||||||
|
onSuccess: () => toast.success('Target deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const columns: Column<Target>[] = [
|
const columns: Column<Target>[] = [
|
||||||
@@ -462,7 +467,7 @@ export default function TargetsPage() {
|
|||||||
label: '',
|
label: '',
|
||||||
render: (t) => (
|
render: (t) => (
|
||||||
<button
|
<button
|
||||||
onClick={(e) => { e.stopPropagation(); if (confirm(`Delete target ${t.name}?`)) deleteMutation.mutate(t.id); }}
|
onClick={(e) => { e.stopPropagation(); setConfirmDelete(t); }}
|
||||||
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
className="text-xs text-red-600 hover:text-red-700 transition-colors"
|
||||||
>
|
>
|
||||||
Delete
|
Delete
|
||||||
@@ -498,6 +503,22 @@ export default function TargetsPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete !== null}
|
||||||
|
title="Delete deployment target"
|
||||||
|
message={
|
||||||
|
confirmDelete
|
||||||
|
? `Delete target ${confirmDelete.name}? Active deployments referencing this target will fail until reconfigured.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="Delete"
|
||||||
|
destructive
|
||||||
|
onConfirm={() => {
|
||||||
|
if (confirmDelete) deleteMutation.mutate(confirmDelete.id);
|
||||||
|
setConfirmDelete(null);
|
||||||
|
}}
|
||||||
|
onCancel={() => setConfirmDelete(null)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
import { useTrackedMutation } from '../hooks/useTrackedMutation';
|
||||||
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
|
import { getTeams, deleteTeam, createTeam, updateTeam } from '../api/client';
|
||||||
import PageHeader from '../components/PageHeader';
|
import PageHeader from '../components/PageHeader';
|
||||||
@@ -156,7 +157,8 @@ export default function TeamsPage() {
|
|||||||
const deleteMutation = useTrackedMutation({
|
const deleteMutation = useTrackedMutation({
|
||||||
mutationFn: deleteTeam,
|
mutationFn: deleteTeam,
|
||||||
invalidates: [['teams']],
|
invalidates: [['teams']],
|
||||||
onError: (err: Error) => alert(`Delete failed: ${err.message}`),
|
onSuccess: () => toast.success('Team deleted'),
|
||||||
|
onError: (err: Error) => toast.error(`Delete failed: ${err.message}`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createMutation = useTrackedMutation({
|
const createMutation = useTrackedMutation({
|
||||||
|
|||||||
@@ -9,7 +9,9 @@ import {
|
|||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import Timestamp from '../../components/Timestamp';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 9 + Phase 10 — Approvals queue.
|
// Bundle 1 Phase 9 + Phase 10 — Approvals queue.
|
||||||
@@ -239,7 +241,7 @@ export default function ApprovalsPage() {
|
|||||||
const query = useQuery({
|
const query = useQuery({
|
||||||
queryKey: ['approvals', filterState],
|
queryKey: ['approvals', filterState],
|
||||||
queryFn: () => listApprovals(filterState),
|
queryFn: () => listApprovals(filterState),
|
||||||
staleTime: 15_000,
|
staleTime: STALE_TIME.REAL_TIME, // approval queue — operator-facing
|
||||||
refetchInterval: 30_000,
|
refetchInterval: 30_000,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -374,7 +376,7 @@ export default function ApprovalsPage() {
|
|||||||
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
|
{isMine && <span className="ml-2 text-amber-700">(you)</span>}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-xs text-ink-muted">
|
<td className="px-3 py-2 text-xs text-ink-muted">
|
||||||
{new Date(req.created_at).toLocaleString()}
|
<Timestamp iso={req.created_at} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2">
|
<td className="px-3 py-2">
|
||||||
{/* Audit 2026-05-11 A-5 — payload preview toggle.
|
{/* Audit 2026-05-11 A-5 — payload preview toggle.
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client';
|
import { authBootstrapAvailable, authRuntimeConfig } from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
import { getTimestampPref, setTimestampPref, type TimestampMode } from '../../api/timestampPref';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — AuthSettingsPage (stub).
|
// Bundle 1 Phase 10 — AuthSettingsPage (stub).
|
||||||
@@ -24,7 +27,7 @@ export default function AuthSettingsPage() {
|
|||||||
const bootstrapQuery = useQuery({
|
const bootstrapQuery = useQuery({
|
||||||
queryKey: ['auth', 'bootstrap', 'available'],
|
queryKey: ['auth', 'bootstrap', 'available'],
|
||||||
queryFn: authBootstrapAvailable,
|
queryFn: authBootstrapAvailable,
|
||||||
staleTime: 60_000,
|
staleTime: STALE_TIME.REFERENCE, // slow-changing auth-runtime data
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
// Audit 2026-05-10 MED-12 — Auth runtime config panel. Gated
|
// Audit 2026-05-10 MED-12 — Auth runtime config panel. Gated
|
||||||
@@ -33,7 +36,7 @@ export default function AuthSettingsPage() {
|
|||||||
const runtimeQuery = useQuery({
|
const runtimeQuery = useQuery({
|
||||||
queryKey: ['auth', 'runtime-config'],
|
queryKey: ['auth', 'runtime-config'],
|
||||||
queryFn: authRuntimeConfig,
|
queryFn: authRuntimeConfig,
|
||||||
staleTime: 60_000,
|
staleTime: STALE_TIME.REFERENCE, // slow-changing auth-runtime data
|
||||||
retry: 0,
|
retry: 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -163,6 +166,67 @@ export default function AuthSettingsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Phase 6 closure (I18N-H3): operator timestamp-display preference. */}
|
||||||
|
<TimestampPreferenceCard />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
// Timestamp-display preference (Phase 6 I18N-H3)
|
||||||
|
// ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function TimestampPreferenceCard() {
|
||||||
|
const [mode, setMode] = useState<TimestampMode>(() => getTimestampPref().mode);
|
||||||
|
const [customTz, setCustomTz] = useState<string>(() => getTimestampPref().customTz);
|
||||||
|
|
||||||
|
function persist(next: { mode: TimestampMode; customTz: string }) {
|
||||||
|
setMode(next.mode);
|
||||||
|
setCustomTz(next.customTz);
|
||||||
|
setTimestampPref(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section className="bg-surface border border-surface-border rounded shadow-sm" data-testid="timestamp-pref-card">
|
||||||
|
<div className="px-4 py-3 border-b border-surface-border">
|
||||||
|
<div className="text-sm font-semibold">Timestamp display</div>
|
||||||
|
<div className="text-xs text-ink-muted">
|
||||||
|
Default UTC matches the server audit log byte-for-byte. Pick Local for browser time;
|
||||||
|
Custom for a specific IANA timezone (e.g. <code>America/New_York</code>).
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="px-4 py-3 text-sm space-y-3">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
{(['utc', 'local', 'custom'] as const).map((m) => (
|
||||||
|
<label key={m} className="flex items-center gap-1.5 cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="timestamp-mode"
|
||||||
|
value={m}
|
||||||
|
checked={mode === m}
|
||||||
|
onChange={() => persist({ mode: m, customTz })}
|
||||||
|
data-testid={`timestamp-mode-${m}`}
|
||||||
|
/>
|
||||||
|
<span className="capitalize">{m === 'utc' ? 'UTC' : m}</span>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
{mode === 'custom' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-ink-muted mb-1">IANA timezone</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={customTz}
|
||||||
|
onChange={(e) => persist({ mode, customTz: e.target.value })}
|
||||||
|
placeholder="America/New_York"
|
||||||
|
spellCheck={false}
|
||||||
|
className="w-full px-2 py-1 border border-surface-border rounded bg-page text-ink font-mono text-xs"
|
||||||
|
data-testid="timestamp-custom-tz-input"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import Timestamp from '../../components/Timestamp';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -232,7 +233,7 @@ export default function BreakglassPage() {
|
|||||||
>
|
>
|
||||||
<td className="py-3 font-mono text-xs">{row.actor_id}</td>
|
<td className="py-3 font-mono text-xs">{row.actor_id}</td>
|
||||||
<td className="py-3 text-xs text-ink-muted">
|
<td className="py-3 text-xs text-ink-muted">
|
||||||
{new Date(row.last_password_change_at).toLocaleString()}
|
<Timestamp iso={row.last_password_change_at} />
|
||||||
</td>
|
</td>
|
||||||
<td className="py-3 text-xs">
|
<td className="py-3 text-xs">
|
||||||
{row.failure_count > 0 ? (
|
{row.failure_count > 0 ? (
|
||||||
@@ -244,7 +245,7 @@ export default function BreakglassPage() {
|
|||||||
<td className="py-3 text-xs text-ink-muted">
|
<td className="py-3 text-xs text-ink-muted">
|
||||||
{isLocked ? (
|
{isLocked ? (
|
||||||
<span className="text-red-700">
|
<span className="text-red-700">
|
||||||
{new Date(row.locked_until!).toLocaleString()}
|
<Timestamp iso={row.locked_until!} />
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
'—'
|
'—'
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import { formatDate } from '../../api/utils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 2 Phase 8 — GroupMappingsPage.
|
// Bundle 2 Phase 8 — GroupMappingsPage.
|
||||||
@@ -203,7 +204,7 @@ export default function GroupMappingsPage() {
|
|||||||
<td className="px-4 py-2 font-mono text-xs">{m.group_name}</td>
|
<td className="px-4 py-2 font-mono text-xs">{m.group_name}</td>
|
||||||
<td className="px-4 py-2 font-mono text-xs">{m.role_id}</td>
|
<td className="px-4 py-2 font-mono text-xs">{m.role_id}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<td className="px-4 py-2 text-ink-muted">
|
||||||
{m.created_at ? new Date(m.created_at).toLocaleDateString() : '—'}
|
{formatDate(m.created_at)}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right">
|
<td className="px-4 py-2 text-right">
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
authListKeys,
|
authListKeys,
|
||||||
authListRoles,
|
authListRoles,
|
||||||
@@ -11,6 +12,8 @@ import {
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — KeysPage.
|
// Bundle 1 Phase 10 — KeysPage.
|
||||||
@@ -33,31 +36,44 @@ export default function KeysPage() {
|
|||||||
const keysQuery = useQuery<AuthKeyEntry[], Error>({
|
const keysQuery = useQuery<AuthKeyEntry[], Error>({
|
||||||
queryKey: ['auth', 'keys'],
|
queryKey: ['auth', 'keys'],
|
||||||
queryFn: authListKeys,
|
queryFn: authListKeys,
|
||||||
staleTime: 30_000,
|
staleTime: STALE_TIME.REAL_TIME, // operator-facing live data
|
||||||
});
|
});
|
||||||
const rolesQuery = useQuery<AuthRole[], Error>({
|
const rolesQuery = useQuery<AuthRole[], Error>({
|
||||||
queryKey: ['auth', 'roles'],
|
queryKey: ['auth', 'roles'],
|
||||||
queryFn: authListRoles,
|
queryFn: authListRoles,
|
||||||
staleTime: 60_000,
|
staleTime: STALE_TIME.REFERENCE, // role catalogue, slow-changing
|
||||||
});
|
});
|
||||||
|
|
||||||
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
|
const [assignTarget, setAssignTarget] = useState<AuthKeyEntry | null>(null);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
// UX-H2 closure — replace window.confirm() with ConfirmDialog.
|
||||||
|
const [confirmRevoke, setConfirmRevoke] = useState<
|
||||||
|
{ entry: AuthKeyEntry; roleID: string } | null
|
||||||
|
>(null);
|
||||||
|
|
||||||
const canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
|
const canAssign = me.hasPerm('auth.role.assign') || me.isAdmin();
|
||||||
const canRevoke = me.hasPerm('auth.role.assign') || me.isAdmin();
|
const canRevoke = me.hasPerm('auth.role.assign') || me.isAdmin();
|
||||||
|
|
||||||
const handleRevoke = async (entry: AuthKeyEntry, roleID: string) => {
|
const handleRevoke = (entry: AuthKeyEntry, roleID: string) => {
|
||||||
if (entry.actor_id === DEMO_ANON) return;
|
if (entry.actor_id === DEMO_ANON) return;
|
||||||
if (!window.confirm(`Revoke ${roleID} from ${entry.actor_id}?`)) return;
|
setConfirmRevoke({ entry, roleID });
|
||||||
|
};
|
||||||
|
|
||||||
|
const performRevoke = async () => {
|
||||||
|
if (!confirmRevoke) return;
|
||||||
|
const { entry, roleID } = confirmRevoke;
|
||||||
|
setConfirmRevoke(null);
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await authRevokeKeyRole(entry.actor_id, roleID);
|
await authRevokeKeyRole(entry.actor_id, roleID);
|
||||||
|
toast.success(`Revoked ${roleID} from ${entry.actor_id}`);
|
||||||
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
|
qc.invalidateQueries({ queryKey: ['auth', 'keys'] });
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setActionError(msg);
|
||||||
|
toast.error(`Revoke failed: ${msg}`);
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
@@ -173,6 +189,19 @@ export default function KeysPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmRevoke !== null}
|
||||||
|
title="Revoke role grant"
|
||||||
|
message={
|
||||||
|
confirmRevoke
|
||||||
|
? `Revoke ${confirmRevoke.roleID} from ${confirmRevoke.entry.actor_id}? The actor will lose every permission scoped to that role on the next request.`
|
||||||
|
: ''
|
||||||
|
}
|
||||||
|
confirmLabel="Revoke"
|
||||||
|
destructive
|
||||||
|
onConfirm={performRevoke}
|
||||||
|
onCancel={() => setConfirmRevoke(null)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
refreshOIDCProvider,
|
refreshOIDCProvider,
|
||||||
type JWKSStatusSnapshot,
|
type JWKSStatusSnapshot,
|
||||||
} from '../../api/client';
|
} from '../../api/client';
|
||||||
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Audit 2026-05-11 Fix 10 — JWKS health panel (MED-7 GUI half).
|
// Audit 2026-05-11 Fix 10 — JWKS health panel (MED-7 GUI half).
|
||||||
@@ -69,7 +70,7 @@ export default function OIDCJWKSStatusPanel({ providerID, canRefresh = true }: P
|
|||||||
queryKey: ['auth', 'oidc', 'jwks-status', providerID],
|
queryKey: ['auth', 'oidc', 'jwks-status', providerID],
|
||||||
queryFn: () => authOIDCJWKSStatus(providerID),
|
queryFn: () => authOIDCJWKSStatus(providerID),
|
||||||
// 30s freshness — operators rarely poll faster than this.
|
// 30s freshness — operators rarely poll faster than this.
|
||||||
staleTime: 30_000,
|
staleTime: STALE_TIME.REAL_TIME, // operator troubleshooting key rotation
|
||||||
// 403 / 404 / 500 — don't drown the page in retries. The panel
|
// 403 / 404 / 500 — don't drown the page in retries. The panel
|
||||||
// hides itself on error (see below).
|
// hides itself on error (see below).
|
||||||
retry: 0,
|
retry: 0,
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useAuthMe } from '../../hooks/useAuthMe';
|
|||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
|
import OIDCTestConnectionPanel from './OIDCTestConnectionPanel';
|
||||||
|
import { formatDate } from '../../api/utils';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 2 Phase 8 — OIDCProvidersPage.
|
// Bundle 2 Phase 8 — OIDCProvidersPage.
|
||||||
@@ -431,7 +432,7 @@ export default function OIDCProvidersPage() {
|
|||||||
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.issuer_url}</td>
|
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.issuer_url}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.client_id}</td>
|
<td className="px-4 py-2 text-ink-muted font-mono text-xs">{p.client_id}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<td className="px-4 py-2 text-ink-muted">
|
||||||
{p.created_at ? new Date(p.created_at).toLocaleDateString() : '—'}
|
{formatDate(p.created_at)}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Link, useParams, useNavigate } from 'react-router-dom';
|
import { Link, useParams, useNavigate } from 'react-router-dom';
|
||||||
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
authGetRole,
|
authGetRole,
|
||||||
authListPermissions,
|
authListPermissions,
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — RoleDetailPage.
|
// Bundle 1 Phase 10 — RoleDetailPage.
|
||||||
@@ -54,17 +57,19 @@ export default function RoleDetailPage() {
|
|||||||
queryKey: ['auth', 'role', id],
|
queryKey: ['auth', 'role', id],
|
||||||
queryFn: () => authGetRole(id),
|
queryFn: () => authGetRole(id),
|
||||||
enabled: Boolean(id),
|
enabled: Boolean(id),
|
||||||
staleTime: 30_000,
|
staleTime: STALE_TIME.REAL_TIME, // operator editing — fresh data
|
||||||
});
|
});
|
||||||
const permsCatalogue = useQuery<AuthPermission[], Error>({
|
const permsCatalogue = useQuery<AuthPermission[], Error>({
|
||||||
queryKey: ['auth', 'permissions'],
|
queryKey: ['auth', 'permissions'],
|
||||||
queryFn: authListPermissions,
|
queryFn: authListPermissions,
|
||||||
staleTime: 5 * 60_000,
|
staleTime: STALE_TIME.CONSTANT, // catalogue — effectively immutable
|
||||||
});
|
});
|
||||||
|
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
const [actionError, setActionError] = useState<string | null>(null);
|
const [actionError, setActionError] = useState<string | null>(null);
|
||||||
|
// UX-H2 closure — replace window.confirm with ConfirmDialog.
|
||||||
|
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||||
|
|
||||||
const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
|
const canEdit = me.hasPerm('auth.role.edit') || me.isAdmin();
|
||||||
const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin();
|
const canDelete = me.hasPerm('auth.role.delete') || me.isAdmin();
|
||||||
@@ -83,15 +88,22 @@ export default function RoleDetailPage() {
|
|||||||
|
|
||||||
const { role, permissions } = detailQuery.data;
|
const { role, permissions } = detailQuery.data;
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = () => {
|
||||||
if (!window.confirm(`Delete role ${role.name}? This cannot be undone.`)) return;
|
setConfirmDelete(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const performDelete = async () => {
|
||||||
|
setConfirmDelete(false);
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
setActionError(null);
|
setActionError(null);
|
||||||
try {
|
try {
|
||||||
await authDeleteRole(role.id);
|
await authDeleteRole(role.id);
|
||||||
|
toast.success(`Role ${role.name} deleted`);
|
||||||
navigate('/auth/roles');
|
navigate('/auth/roles');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setActionError(err instanceof Error ? err.message : String(err));
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
setActionError(msg);
|
||||||
|
toast.error(`Delete failed: ${msg}`);
|
||||||
} finally {
|
} finally {
|
||||||
setSubmitting(false);
|
setSubmitting(false);
|
||||||
}
|
}
|
||||||
@@ -260,6 +272,15 @@ export default function RoleDetailPage() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
<ConfirmDialog
|
||||||
|
open={confirmDelete}
|
||||||
|
title="Delete role"
|
||||||
|
message={`Delete role ${role.name}? Every actor currently holding this role grant will lose those permissions. This cannot be undone.`}
|
||||||
|
confirmLabel="Delete role"
|
||||||
|
destructive
|
||||||
|
onConfirm={performDelete}
|
||||||
|
onCancel={() => setConfirmDelete(false)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { authListRoles, authCreateRole, type AuthRole } from '../../api/client';
|
|||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
import { STALE_TIME } from '../../api/queryConstants';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Bundle 1 Phase 10 — RolesPage.
|
// Bundle 1 Phase 10 — RolesPage.
|
||||||
@@ -139,7 +140,7 @@ export default function RolesPage() {
|
|||||||
const rolesQuery = useQuery<AuthRole[], Error>({
|
const rolesQuery = useQuery<AuthRole[], Error>({
|
||||||
queryKey: ['auth', 'roles'],
|
queryKey: ['auth', 'roles'],
|
||||||
queryFn: authListRoles,
|
queryFn: authListRoles,
|
||||||
staleTime: 30_000,
|
staleTime: STALE_TIME.REFERENCE, // role catalogue — slow-changing
|
||||||
});
|
});
|
||||||
|
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { useQuery, useQueryClient } from '@tanstack/react-query';
|
|||||||
import { listSessions, revokeSession, type SessionInfo } from '../../api/client';
|
import { listSessions, revokeSession, type SessionInfo } from '../../api/client';
|
||||||
import { useAuthMe } from '../../hooks/useAuthMe';
|
import { useAuthMe } from '../../hooks/useAuthMe';
|
||||||
import PageHeader from '../../components/PageHeader';
|
import PageHeader from '../../components/PageHeader';
|
||||||
|
import Timestamp from '../../components/Timestamp';
|
||||||
import ErrorState from '../../components/ErrorState';
|
import ErrorState from '../../components/ErrorState';
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
@@ -166,7 +167,7 @@ export default function SessionsPage() {
|
|||||||
<span className="ml-1 text-ink-muted">({s.actor_type})</span>
|
<span className="ml-1 text-ink-muted">({s.actor_type})</span>
|
||||||
{isOwn && (
|
{isOwn && (
|
||||||
<span
|
<span
|
||||||
className="ml-2 inline-block px-1.5 py-0.5 text-[10px] rounded bg-brand-50 text-brand-700"
|
className="ml-2 inline-block px-1.5 py-0.5 text-2xs rounded bg-brand-50 text-brand-700"
|
||||||
data-testid={`session-self-pill-${s.id}`}
|
data-testid={`session-self-pill-${s.id}`}
|
||||||
>
|
>
|
||||||
you
|
you
|
||||||
@@ -175,10 +176,10 @@ export default function SessionsPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">{s.ip_address || '—'}</td>
|
<td className="px-4 py-2 text-ink-muted">{s.ip_address || '—'}</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<td className="px-4 py-2 text-ink-muted">
|
||||||
{s.last_seen_at ? new Date(s.last_seen_at).toLocaleString() : '—'}
|
<Timestamp iso={s.last_seen_at} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-ink-muted">
|
<td className="px-4 py-2 text-ink-muted">
|
||||||
{s.absolute_expires_at ? new Date(s.absolute_expires_at).toLocaleString() : '—'}
|
<Timestamp iso={s.absolute_expires_at} />
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-2 text-right">
|
<td className="px-4 py-2 text-right">
|
||||||
{showRevoke && (
|
{showRevoke && (
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user