mirror of
https://github.com/hanzoai/kms.git
synced 2026-10-17 11:18:16 +00:00
The console signs in with Hanzo IAM and manages secrets over cloud's /v1/kms
The console now calls the KMS API cloud serves on kms.hanzo.ai: the org comes from the token, a listing shows every secret by path and environment, and a value is opened, replaced or deleted one secret at a time. Sign-in is authorization code with PKCE through @hanzo/iam against the issuer /v1/kms/config names, as the <brand>-kms client. Pages with no API behind them (keys, audit, projects, certificates) are gone. kmsd no longer serves the console; it ships as a static site. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+3
-4
@@ -1,11 +1,10 @@
|
||||
# Build artifacts and dependencies
|
||||
backend/node_modules
|
||||
frontend/node_modules
|
||||
backend/frontend-build
|
||||
**/node_modules
|
||||
**/.next
|
||||
**/dist
|
||||
!frontend/dist
|
||||
|
||||
# The console is a static site, built and published outside this image
|
||||
frontend
|
||||
|
||||
# Docker files
|
||||
.dockerignore
|
||||
|
||||
+2
-14
@@ -1,15 +1,5 @@
|
||||
# Hanzo KMS — thin wrapper over luxfi/kms.
|
||||
#
|
||||
# Build is now pure Go (no SQLCipher, no Base, no TS frontend toolchain
|
||||
# required). The TS dashboard ships as a static asset built in a separate
|
||||
# stage and copied verbatim.
|
||||
|
||||
FROM ghcr.io/hanzoai/nodejs:v24.18.0 AS frontend
|
||||
WORKDIR /src/frontend
|
||||
COPY frontend/package.json frontend/pnpm-lock.yaml ./
|
||||
RUN corepack enable pnpm && pnpm install --frozen-lockfile
|
||||
COPY frontend/ .
|
||||
RUN pnpm build
|
||||
# Hanzo KMS: a thin wrapper over luxfi/kms, built in pure Go. The console in
|
||||
# frontend/ is a static site published on its own and is not part of this image.
|
||||
|
||||
FROM golang:1.26-bookworm AS build
|
||||
|
||||
@@ -84,7 +74,6 @@ COPY --from=build --chown=1000:1000 /emptydir /data/hanzo-kms
|
||||
|
||||
COPY --from=build /kmsd /usr/local/bin/kmsd
|
||||
COPY --from=build /kms /usr/local/bin/kms
|
||||
COPY --from=frontend --chown=1000:1000 /src/frontend/dist /app/frontend
|
||||
|
||||
# Hanzo defaults — the binary already defaults to these, env vars only
|
||||
# document them for operators inspecting the image.
|
||||
@@ -92,7 +81,6 @@ ENV KMS_LISTEN=:8443 \
|
||||
KMS_ZAP_PORT=9653 \
|
||||
KMS_DATA_DIR=/data/hanzo-kms \
|
||||
KMS_NODE_ID=hanzo-kms-0 \
|
||||
KMS_FRONTEND_DIR=/app/frontend \
|
||||
BRAND_NAME=Hanzo
|
||||
|
||||
USER 1000:1000
|
||||
|
||||
@@ -11,15 +11,16 @@
|
||||
// (consensus.go) and which is disabled unless that snapshot is present.
|
||||
//
|
||||
// There is no HTTP secret plane here. Secret reads and writes over HTTP
|
||||
// are served by cloud (apps/kms) on /v1/kms/orgs/{org}/secrets, which
|
||||
// folds the caller's org into the storage key so one tenant's path
|
||||
// cannot name another's record. This package therefore makes no
|
||||
// authorization decision at all: IAM owns identity and permissions, and
|
||||
// verifyJWT answers only "who signed this token".
|
||||
// are served by cloud (apps/kms) on /v1/kms/secrets, which takes the
|
||||
// org from the caller's token and folds it into the storage key, so one
|
||||
// tenant's path cannot name another's record. This package therefore
|
||||
// makes no authorization decision at all: IAM owns identity and
|
||||
// permissions, and verifyJWT answers only "who signed this token".
|
||||
//
|
||||
// The HTTP surface is health, the IAM client-credentials login proxy,
|
||||
// and the dashboard SPA. embed.go owns that route assembly plus the
|
||||
// transport wiring, and exposes Embed(ctx, cfg) (*Embedded, error).
|
||||
// The HTTP surface is health and the IAM client-credentials login proxy.
|
||||
// The console is a static site (frontend/) that calls cloud. embed.go
|
||||
// owns the route assembly plus the transport wiring, and exposes
|
||||
// Embed(ctx, cfg) (*Embedded, error).
|
||||
package kms
|
||||
|
||||
import (
|
||||
@@ -193,15 +194,6 @@ func Embed(ctx context.Context, cfg EmbedConfig) (*Embedded, error) {
|
||||
registerHealth(mux)
|
||||
registerAuth(mux, cfg.IAMEndpoint)
|
||||
|
||||
// Frontend SPA. The Dockerfile builds frontend/ → /app/frontend and
|
||||
// sets KMS_FRONTEND_DIR. When the env var resolves to a directory
|
||||
// containing index.html, register a catch-all handler at "/" that
|
||||
// serves static assets directly and falls back to index.html for
|
||||
// SPA client-side routes (/login, /dashboard, etc.). Anything that
|
||||
// hits an API path (/healthz, /v1/kms/**) takes the more-specific
|
||||
// route registered above; the SPA only fires on the catch-all.
|
||||
registerFrontend(mux)
|
||||
|
||||
em.mux = mux
|
||||
em.handler = methodAllowlist(stripIdentityHeaders(mux))
|
||||
|
||||
@@ -695,80 +687,3 @@ func (zapdbLogger) Infof(format string, args ...interface{}) {
|
||||
func (zapdbLogger) Debugf(format string, args ...interface{}) {
|
||||
log.Debug(fmt.Sprintf(format, args...))
|
||||
}
|
||||
|
||||
// registerFrontend adds a catch-all `/` handler that serves the React
|
||||
// SPA from KMS_FRONTEND_DIR (set by the Dockerfile to /app/frontend).
|
||||
//
|
||||
// The mux is already populated with explicit API routes (/healthz,
|
||||
// /v1/kms/**, /v1/mpc/**) — those take precedence because Go's
|
||||
// ServeMux longest-match-wins rule fires the more-specific pattern
|
||||
// before this one.
|
||||
//
|
||||
// Static assets resolve directly from disk. Anything else (e.g.
|
||||
// /login, /dashboard) returns index.html so the React Router can
|
||||
// handle client-side routing without a server round-trip.
|
||||
//
|
||||
// If KMS_FRONTEND_DIR is unset OR doesn't contain index.html, the
|
||||
// handler returns a tiny JSON status object — preserves the
|
||||
// "no UI here, but the service is alive" signal we used to get
|
||||
// from the bare 404 without surprising operators.
|
||||
func registerFrontend(mux *http.ServeMux) {
|
||||
dir := strings.TrimSpace(os.Getenv("KMS_FRONTEND_DIR"))
|
||||
indexPath := ""
|
||||
if dir != "" {
|
||||
candidate := dir + "/index.html"
|
||||
if _, err := os.Stat(candidate); err == nil {
|
||||
indexPath = candidate
|
||||
}
|
||||
}
|
||||
|
||||
if indexPath == "" {
|
||||
// Fallback: tiny JSON service banner for when the SPA isn't bundled.
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{
|
||||
"service": "kms",
|
||||
"version": Version,
|
||||
"status": "ok",
|
||||
"docs": "/v1/kms",
|
||||
})
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
fileServer := http.FileServer(http.Dir(dir))
|
||||
mux.HandleFunc("GET /", func(w http.ResponseWriter, r *http.Request) {
|
||||
// API paths starting with /v1/ or /healthz never reach here —
|
||||
// ServeMux fires the more-specific handler first. Defense in
|
||||
// depth: bail out if the path looks like an API route.
|
||||
//
|
||||
// /api/ is explicitly 404'd: this service has ZERO /api/ surface
|
||||
// (the ONE canonical prefix is /v1/kms). Without this guard the SPA
|
||||
// fallback below would answer GET /api/v1/... with index.html (200),
|
||||
// masquerading as a live legacy /api/ backend. There is no /api/;
|
||||
// make that unambiguous at the wire.
|
||||
if strings.HasPrefix(r.URL.Path, "/v1/") ||
|
||||
strings.HasPrefix(r.URL.Path, "/api/") || r.URL.Path == "/api" ||
|
||||
r.URL.Path == "/healthz" || r.URL.Path == "/health" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
// SPA fallback: serve index.html for any path that isn't a
|
||||
// concrete file on disk. This is the standard React Router
|
||||
// pattern (matches Vite's default dev-server behaviour).
|
||||
clean := strings.TrimPrefix(r.URL.Path, "/")
|
||||
if clean == "" {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
if _, err := os.Stat(dir + "/" + clean); err != nil {
|
||||
http.ServeFile(w, r, indexPath)
|
||||
return
|
||||
}
|
||||
fileServer.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
+24
-52
@@ -1,64 +1,36 @@
|
||||
# @hanzo/kms-frontend
|
||||
# Hanzo KMS console
|
||||
|
||||
Hanzo KMS admin SPA.
|
||||
The app at kms.hanzo.ai. It signs in with Hanzo IAM and manages the signed-in
|
||||
organization's secrets over the KMS API that cloud serves on the same host.
|
||||
|
||||
- Vite + React 19 + TypeScript + Tailwind v4
|
||||
- `@hanzo/brand` for brand tokens
|
||||
- `@tanstack/react-query` for fetch state
|
||||
- `wouter` with hash routing for deep links
|
||||
- Vite, React 19, TypeScript, Tailwind v4
|
||||
- `@hanzo/iam/browser` for sign-in: authorization code with PKCE against the
|
||||
issuer `GET /v1/kms/config` names, as the `<brand>-kms` client
|
||||
- `@tanstack/react-query` for fetch state, `wouter` for routes
|
||||
|
||||
Talks to `/v1/kms/*` on the kmsd HTTP server. No backend coupling
|
||||
beyond that.
|
||||
## API it calls
|
||||
|
||||
## Layout
|
||||
| Method | Path | Page |
|
||||
|--------|------|------|
|
||||
| GET | `/v1/kms/config` | before sign-in |
|
||||
| GET | `/v1/kms/health` | Status |
|
||||
| GET | `/v1/kms/secrets?path=&env=` | Secrets |
|
||||
| GET, DELETE | `/v1/kms/secrets/{path}/{name}?env=` | Secrets |
|
||||
| POST | `/v1/kms/secrets` | Secrets |
|
||||
|
||||
```
|
||||
src/
|
||||
components/
|
||||
AdminShell.tsx — sidebar + topbar + content chrome
|
||||
CollectionCRUD.tsx — flat-collection table primitive
|
||||
Button.tsx — buttons + inputs + cards + badges
|
||||
lib/
|
||||
api.ts — typed fetch wrapper + auth helpers
|
||||
cn.ts — clsx classname join
|
||||
pages/
|
||||
Login.tsx — POST /v1/kms/auth/login
|
||||
Secrets.tsx — bespoke path-tree browser
|
||||
Keys.tsx — flat CollectionCRUD over GET /v1/kms/keys
|
||||
Audit.tsx — GET /v1/kms/audit/stats
|
||||
Status.tsx — GET /v1/kms/health + /v1/kms/status
|
||||
EndpointGap.tsx — honest stub for unbacked admin pages
|
||||
App.tsx — router + AdminShell wiring
|
||||
main.tsx — entrypoint
|
||||
```
|
||||
|
||||
## Decisions
|
||||
|
||||
- **No tree listing for secrets.** The canonical surface is
|
||||
path-addressed CRUD only. The "tree view" is a session-local history
|
||||
of paths the operator has opened. Persisted to localStorage so
|
||||
reloads don't lose context.
|
||||
- **Hash routing.** kmsd serves the SPA from `/` and ingress is
|
||||
path-stripped at root. Hash routing keeps deep links resilient to
|
||||
proxy reconfiguration.
|
||||
- **Token in localStorage.** `/v1/kms/auth/login` exchanges
|
||||
`clientId`/`clientSecret` for an IAM access token. The token sits in
|
||||
`localStorage.KMS_TOKEN` and is sent as `Authorization: Bearer …` on
|
||||
every request.
|
||||
- **Pages with no backing endpoints** (projects, workspaces,
|
||||
identities, integrations, certificates) render a single `EndpointGap`
|
||||
component that lists the routes the backend will need.
|
||||
The org is never in a URL: cloud reads it from the token.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
pnpm dev # http://localhost:5173 — proxies /v1/kms to :8443
|
||||
pnpm build # → dist/, picked up by kmsd via KMS_FRONTEND_DIR
|
||||
pnpm preview
|
||||
pnpm typecheck
|
||||
pnpm dev # http://localhost:5173, proxies /v1 to a local cloud on :8000
|
||||
pnpm test # node:test over src/lib/kms.ts
|
||||
pnpm build # typecheck, then dist/
|
||||
```
|
||||
|
||||
The image build copies `dist/` to `/app/frontend` and kmsd serves it
|
||||
from `KMS_FRONTEND_DIR`. API paths are SPA-fallthrough-protected by
|
||||
kmsd's mux.
|
||||
## Shipping
|
||||
|
||||
`hanzo.yml` declares the site: the forge runs `.hanzo/workflows/cicd.yml`,
|
||||
which builds `dist/` and publishes it to the Sites plane as `kms`. The ingress
|
||||
serves it on kms.hanzo.ai and sends `/v1` to cloud.
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<title>Hanzo KMS</title>
|
||||
</head>
|
||||
<body class="bg-neutral-950 text-neutral-100 antialiased">
|
||||
<body class="bg-black text-neutral-100 antialiased">
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
|
||||
@@ -2,17 +2,18 @@
|
||||
"name": "@hanzo/kms-frontend",
|
||||
"private": true,
|
||||
"version": "3.0.0",
|
||||
"description": "Hanzo KMS admin SPA \u2014 composed from an in-app AdminShell + CollectionCRUD over @hanzo/brand. Talks to /v1/kms/* on the kmsd binary.",
|
||||
"description": "Hanzo KMS console: a static app that signs in with Hanzo IAM and manages an organization's secrets over /v1/kms.",
|
||||
"type": "module",
|
||||
"packageManager": "[email protected]",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b --emitDeclarationOnly false && vite build",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"typecheck": "tsc --noEmit"
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --test --test-reporter=tap --experimental-strip-types src/lib/kms.test.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hanzo/brand": "^1.4.8",
|
||||
"@hanzo/iam": "^0.21.18",
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"clsx": "^2.1.1",
|
||||
"react": "^19.0.0",
|
||||
|
||||
Generated
+72
-6
@@ -8,9 +8,9 @@ importers:
|
||||
|
||||
.:
|
||||
dependencies:
|
||||
'@hanzo/brand':
|
||||
specifier: ^1.3.0
|
||||
version: 1.3.0
|
||||
'@hanzo/iam':
|
||||
specifier: ^0.21.18
|
||||
version: 0.21.18([email protected]([email protected]))([email protected])
|
||||
'@tanstack/react-query':
|
||||
specifier: ^5.62.7
|
||||
version: 5.100.11([email protected])
|
||||
@@ -297,8 +297,17 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-9tgIeX6HUVz062UiR/E4JyqvZBM+b+yDaI9lQgnm7dklVrPlAScY7ZP3OInBiranRNjV8QthIll+VOSzQkvX3w==}
|
||||
'@hanzo/[email protected]':
|
||||
resolution: {integrity: sha512-IQNxp9xO3X5ZBizzstWPmRneMk4sV4wpMNmvpV3+u/Y0n54PITLFMdiNkJr43GeFy/YH0oRefe9HXgUd9VOghw==}
|
||||
engines: {node: '>=18'}
|
||||
peerDependencies:
|
||||
react: '>=17'
|
||||
react-dom: '>=17'
|
||||
peerDependenciesMeta:
|
||||
react:
|
||||
optional: true
|
||||
react-dom:
|
||||
optional: true
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
resolution: {integrity: sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==}
|
||||
@@ -569,6 +578,10 @@ packages:
|
||||
peerDependencies:
|
||||
vite: ^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-ir1UPr3dkwexU7FdV8qBBbNDRUhMmIekYMFZfi+C/sLNnRESKPl23nB9b2pltqfOQNnGzsDdId90AEtG5tCx4A==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-MujYO3eP72uvmSE0i4wltsodRfIpZATP3jvzRNRGGxgzId7aVocVJJV3nf01qnzzKFGxQVC9bpWxl5cjxTr/7Q==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -646,6 +659,9 @@ packages:
|
||||
resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-9NiFmJEex0sy2Dk58j2UGBSHgUs2ypF9eZSu4L6vjOX3Dp96Sw1F3uL+H+D1sx02jZZdzUT0HgvCy59CuvXcWw==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -659,6 +675,9 @@ packages:
|
||||
engines: {node: '>=6'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-hV6SmWnyQ8U1QXhhqbqYBHIStNnscG7A5XbGodgdHhf/8N5PwhYpJA5U/yHDjYwPYTd8uNGXqq416XctuvK2PA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
@@ -749,6 +768,17 @@ packages:
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-5WUyunoPMsvvEhS8AxHtRzP+oA8UCkJ7YRxatWKjngndhDGLiqEVAQKWjFAiAiuL8zMRGzGSJxFnLetoa43qGQ==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-JtFnB+8nxDEXgNyniwz573xxbKSOu3R8D40xQKqcjwJ2CDkYqUDI53o6IuzDJBx60Z8VKCm271+t8iFjakrl8Q==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-cjsQbOrXIDE4P8nNb3FQRCCmJJ/utnFKEz2NX209f7KOHPoX18gF7gBzBbLLsj2/je4KrgiwLLGjf0lm9rtTBA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-CB97UUvDKJde2V0KDWWB3lyf6PC3FaZP7YxZ2G8OAtn9p4HI9j9JLP9qjOGZFvyl8uwNT8qM+hGnz/n16NI7oA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==}
|
||||
|
||||
@@ -809,6 +839,9 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-IevTus0SbGwQzYh3+fRsAMTVVPOoIVufzacXcHPmdlle1jUpq7BRL+mw3dgeLanvGZdwwbWhRV6XrcFNdBmjWA==}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==}
|
||||
hasBin: true
|
||||
@@ -820,6 +853,10 @@ packages:
|
||||
peerDependencies:
|
||||
react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==}
|
||||
engines: {node: '>= 0.4.0'}
|
||||
|
||||
[email protected]:
|
||||
resolution: {integrity: sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==}
|
||||
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
|
||||
@@ -1062,7 +1099,14 @@ snapshots:
|
||||
'@esbuild/[email protected]':
|
||||
optional: true
|
||||
|
||||
'@hanzo/[email protected]': {}
|
||||
'@hanzo/[email protected]([email protected]([email protected]))([email protected])':
|
||||
dependencies:
|
||||
jose: 6.2.12
|
||||
libphonenumber-js: 1.13.13
|
||||
passport-oauth2: 1.8.0
|
||||
optionalDependencies:
|
||||
react: 19.2.6
|
||||
react-dom: 19.2.6([email protected])
|
||||
|
||||
'@jridgewell/[email protected]':
|
||||
dependencies:
|
||||
@@ -1279,6 +1323,8 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
@@ -1354,12 +1400,16 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
optional: true
|
||||
|
||||
@@ -1425,6 +1475,18 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]:
|
||||
dependencies:
|
||||
base64url: 3.0.1
|
||||
oauth: 0.10.2
|
||||
passport-strategy: 1.0.0
|
||||
uid2: 0.0.4
|
||||
utils-merge: 1.0.1
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
@@ -1494,6 +1556,8 @@ snapshots:
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected]):
|
||||
dependencies:
|
||||
browserslist: 4.28.2
|
||||
@@ -1504,6 +1568,8 @@ snapshots:
|
||||
dependencies:
|
||||
react: 19.2.6
|
||||
|
||||
[email protected]: {}
|
||||
|
||||
[email protected]([email protected])([email protected]):
|
||||
dependencies:
|
||||
esbuild: 0.25.12
|
||||
|
||||
+95
-155
@@ -1,175 +1,115 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Router, Route, Switch } from 'wouter'
|
||||
import { useHashLocation } from 'wouter/use-hash-location'
|
||||
import { decodeToken, getOrg, getToken, setOrg, setToken } from '@/lib/api'
|
||||
import { AdminShell, type NavSection } from '@/components/AdminShell'
|
||||
import { Badge, Button } from '@/components/Button'
|
||||
import { LoginPage } from '@/pages/Login'
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link, Route, Switch, useLocation } from 'wouter'
|
||||
import { fetchConfig } from '@/lib/api'
|
||||
import type { Config } from '@/lib/kms'
|
||||
import { callbackPath, configure, finishSignIn, signIn, signOut, signedIn, who } from '@/lib/session'
|
||||
import { Shell } from '@/components/Shell'
|
||||
import { Button, Card, Notice } from '@/components/ui'
|
||||
import { SecretsPage } from '@/pages/Secrets'
|
||||
import { KeysPage } from '@/pages/Keys'
|
||||
import { AuditPage } from '@/pages/Audit'
|
||||
import { StatusPage } from '@/pages/Status'
|
||||
import { EndpointGap } from '@/pages/EndpointGap'
|
||||
|
||||
const sections: NavSection[] = [
|
||||
{
|
||||
title: 'Secrets',
|
||||
items: [{ href: '/', label: 'Browser' }],
|
||||
},
|
||||
{
|
||||
title: 'Cryptography',
|
||||
items: [
|
||||
{ href: '/keys', label: 'MPC keys' },
|
||||
{ href: '/certificates', label: 'Certificates' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Identity',
|
||||
items: [
|
||||
{ href: '/identities', label: 'Identities' },
|
||||
{ href: '/projects', label: 'Projects' },
|
||||
{ href: '/workspaces', label: 'Workspaces' },
|
||||
{ href: '/integrations', label: 'Integrations' },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: 'Observability',
|
||||
items: [
|
||||
{ href: '/audit', label: 'Audit' },
|
||||
{ href: '/status', label: 'Status' },
|
||||
],
|
||||
},
|
||||
]
|
||||
// The console loads its configuration, finishes a sign-in that returned to the
|
||||
// callback path, and shows either the sign-in screen or the pages.
|
||||
|
||||
export function App() {
|
||||
// Force a re-render after sign-in/sign-out so AdminShell flips on the
|
||||
// token presence. The hash router handles the deep links itself.
|
||||
const [, setTick] = useState(0)
|
||||
const refresh = () => setTick((v) => v + 1)
|
||||
const config = useQuery({ queryKey: ['config'], queryFn: fetchConfig, staleTime: Infinity })
|
||||
if (config.isPending) return <Centered>Loading</Centered>
|
||||
if (config.isError) {
|
||||
return (
|
||||
<Centered>
|
||||
<Notice>{config.error.message}</Notice>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
return <Console config={config.data} />
|
||||
}
|
||||
|
||||
const token = getToken()
|
||||
const claims = decodeToken(token)
|
||||
function Console({ config }: { config: Config }) {
|
||||
const [, navigate] = useLocation()
|
||||
const [ready, setReady] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const product = `${config.brand.charAt(0).toUpperCase()}${config.brand.slice(1)} KMS`
|
||||
|
||||
useEffect(() => {
|
||||
// Auto sign-out when the cached token is expired. The server would
|
||||
// reject the next request anyway; this just bounces faster.
|
||||
if (claims?.exp && claims.exp * 1000 < Date.now()) {
|
||||
setToken(null)
|
||||
refresh()
|
||||
document.title = product
|
||||
configure(config)
|
||||
if (window.location.pathname !== callbackPath) {
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
}, [claims?.exp])
|
||||
finishSignIn()
|
||||
.then((to) => navigate(to, { replace: true }))
|
||||
.catch((e: Error) => {
|
||||
setError(e.message)
|
||||
navigate('/', { replace: true })
|
||||
})
|
||||
.finally(() => setReady(true))
|
||||
}, [config])
|
||||
|
||||
if (!token) {
|
||||
return <LoginPage onSuccess={refresh} />
|
||||
if (!ready) return <Centered>Signing in</Centered>
|
||||
if (!signedIn()) return <SignIn product={product} issuer={config.issuer} error={error} />
|
||||
|
||||
return (
|
||||
<Shell product={product} user={who()} onSignOut={() => void signOut()}>
|
||||
<Switch>
|
||||
<Route path="/">
|
||||
<SecretsPage />
|
||||
</Route>
|
||||
<Route path="/status">
|
||||
<StatusPage config={config} />
|
||||
</Route>
|
||||
<Route>
|
||||
<div className="p-6 text-[13px] text-neutral-400">
|
||||
No page here. <Link href="/" className="text-neutral-100 underline">Go to secrets</Link>
|
||||
</div>
|
||||
</Route>
|
||||
</Switch>
|
||||
</Shell>
|
||||
)
|
||||
}
|
||||
|
||||
function SignIn({ product, issuer, error }: { product: string; issuer: string; error: string | null }) {
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [failed, setFailed] = useState<string | null>(null)
|
||||
const host = issuer.replace(/^https?:\/\//, '')
|
||||
|
||||
function start() {
|
||||
setBusy(true)
|
||||
setFailed(null)
|
||||
signIn().catch((e: Error) => {
|
||||
setFailed(e.message)
|
||||
setBusy(false)
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Router hook={useHashLocation}>
|
||||
<AdminShell
|
||||
sections={sections}
|
||||
title="Hanzo KMS"
|
||||
topRight={<TopRight subject={claims?.sub} onSignOut={refresh} />}
|
||||
>
|
||||
<Switch>
|
||||
<Route path="/" component={SecretsPage} />
|
||||
<Route path="/keys" component={KeysPage} />
|
||||
<Route path="/audit" component={AuditPage} />
|
||||
<Route path="/status" component={StatusPage} />
|
||||
<Route path="/certificates">
|
||||
<EndpointGap
|
||||
title="Certificates"
|
||||
description="X.509 issuance, sync, lifecycle alerts."
|
||||
endpoints={[
|
||||
'GET /v1/kms/orgs/{org}/certs',
|
||||
'POST /v1/kms/orgs/{org}/certs',
|
||||
'GET /v1/kms/orgs/{org}/certs/{id}',
|
||||
]}
|
||||
rationale="kmsd serves the ZAP-encoded certificate primitives via the in-process server, but the HTTP surface has not been wired yet. Read-only view ships first; CA issuance follows once the storage shape lands."
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/identities">
|
||||
<EndpointGap
|
||||
title="Identities"
|
||||
description="Universal Auth machine identities — clientId/clientSecret pairs that exchange for IAM access tokens."
|
||||
endpoints={[
|
||||
'POST /v1/iam/users/identities (managed via IAM)',
|
||||
'GET /v1/iam/users/identities',
|
||||
]}
|
||||
rationale="Machine identities live in Hanzo IAM, not in kmsd. Use the IAM admin UI to mint and revoke clients; KMS only enforces the issued tokens."
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/projects">
|
||||
<EndpointGap
|
||||
title="Projects"
|
||||
description="Project / namespace organisation for secrets."
|
||||
endpoints={[
|
||||
'GET /v1/kms/orgs/{org}/projects',
|
||||
'POST /v1/kms/orgs/{org}/projects',
|
||||
]}
|
||||
rationale="The current secrets API is path-addressed (the path itself is the namespace). A formal projects resource is on the HIP-0027 roadmap; for now, treat the first segment of the secret path as the project."
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/workspaces">
|
||||
<EndpointGap
|
||||
title="Workspaces"
|
||||
description="Per-environment workspace shape (legacy KMS terminology)."
|
||||
endpoints={['GET /v1/kms/orgs/{org}/workspaces']}
|
||||
rationale="Workspaces are not a first-class concept on the canonical surface — environments are passed per-secret via ?env=. Kept here for migration tooling that maps legacy workspace IDs."
|
||||
/>
|
||||
</Route>
|
||||
<Route path="/integrations">
|
||||
<EndpointGap
|
||||
title="Integrations"
|
||||
description="Secret syncs to GitHub, Vercel, AWS, Terraform, etc."
|
||||
endpoints={['GET /v1/kms/orgs/{org}/integrations']}
|
||||
rationale="Integrations are operator-side jobs that read from KMS via the kmsclient. They are not modelled on the kmsd HTTP surface — register them through the platform operator's CRDs."
|
||||
/>
|
||||
</Route>
|
||||
</Switch>
|
||||
</AdminShell>
|
||||
</Router>
|
||||
<Centered>
|
||||
<Card className="w-full max-w-sm p-6">
|
||||
<div className="mb-5 flex items-center gap-2">
|
||||
<img src="/favicon.svg" alt="" className="h-7 w-7" />
|
||||
<div className="text-base font-semibold text-neutral-50">{product}</div>
|
||||
</div>
|
||||
<p className="mb-5 text-[13px] text-neutral-400">
|
||||
Your organization's secrets, sealed at rest. Sign in with your account at {host} to see them.
|
||||
</p>
|
||||
{(error || failed) && (
|
||||
<div className="mb-4">
|
||||
<Notice>{failed ?? error}</Notice>
|
||||
</div>
|
||||
)}
|
||||
<Button variant="primary" className="w-full" onClick={start} disabled={busy}>
|
||||
{busy ? 'Opening sign-in' : `Sign in with ${host}`}
|
||||
</Button>
|
||||
</Card>
|
||||
</Centered>
|
||||
)
|
||||
}
|
||||
|
||||
function TopRight({ subject, onSignOut }: { subject?: string; onSignOut: () => void }) {
|
||||
const org = getOrg()
|
||||
function Centered({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<span className="text-neutral-500">org</span>
|
||||
<OrgSwitcher
|
||||
value={org}
|
||||
onChange={(v) => {
|
||||
setOrg(v)
|
||||
window.location.reload()
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{subject && (
|
||||
<div className="flex items-center gap-2 text-[12px]">
|
||||
<span className="text-neutral-500">subject</span>
|
||||
<Badge>{subject}</Badge>
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setToken(null)
|
||||
onSignOut()
|
||||
}}
|
||||
>
|
||||
Sign out
|
||||
</Button>
|
||||
<div className="flex min-h-screen items-center justify-center bg-black p-6 text-[13px] text-neutral-400">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function OrgSwitcher({ value, onChange }: { value: string; onChange: (v: string) => void }) {
|
||||
return (
|
||||
<input
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-28 rounded-md border border-neutral-800 bg-neutral-950 px-2 py-1 font-mono text-[12px] text-neutral-100 focus:border-[color:var(--color-brand)] focus:outline-none"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,153 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'wouter'
|
||||
import { brand } from '@hanzo/brand'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
// AdminShell — the chrome wrapper. Sidebar on the left, topbar across
|
||||
// the top, content fills the rest. One layout for every admin page.
|
||||
// Pages compose by passing children; the shell never knows what's in
|
||||
// them.
|
||||
|
||||
export interface NavItem {
|
||||
href: string
|
||||
label: string
|
||||
badge?: string
|
||||
}
|
||||
|
||||
export interface NavSection {
|
||||
title: string
|
||||
items: NavItem[]
|
||||
}
|
||||
|
||||
export interface AdminShellProps {
|
||||
sections: NavSection[]
|
||||
title: string
|
||||
topRight?: ReactNode
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdminShell({ sections, title, topRight, children }: AdminShellProps) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-950 text-neutral-100">
|
||||
<Sidebar sections={sections} title={title} />
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<TopBar title={title} topRight={topRight} />
|
||||
<main className="flex-1 overflow-auto bg-neutral-950">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface SidebarProps {
|
||||
sections: NavSection[]
|
||||
title: string
|
||||
}
|
||||
|
||||
function Sidebar({ sections, title }: SidebarProps) {
|
||||
return (
|
||||
<aside className="flex w-60 shrink-0 flex-col border-r border-neutral-900 bg-neutral-950">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 px-4 py-4 text-sm font-semibold text-neutral-50"
|
||||
>
|
||||
<BrandMark />
|
||||
<span>{title}</span>
|
||||
</Link>
|
||||
<nav className="flex flex-1 flex-col gap-3 overflow-y-auto px-2 pb-4">
|
||||
{sections.map((s) => (
|
||||
<NavSectionView key={s.title} section={s} />
|
||||
))}
|
||||
</nav>
|
||||
<SidebarFooter />
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function NavSectionView({ section }: { section: NavSection }) {
|
||||
return (
|
||||
<div className="flex flex-col gap-px">
|
||||
<div className="px-3 pb-1 pt-2 text-[10px] font-semibold uppercase tracking-[0.08em] text-neutral-500">
|
||||
{section.title}
|
||||
</div>
|
||||
{section.items.map((it) => (
|
||||
<NavLink key={it.href} item={it} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavLink({ item }: { item: NavItem }) {
|
||||
const [location] = useLocation()
|
||||
const active =
|
||||
item.href === '/' ? location === '/' : location === item.href || location.startsWith(`${item.href}/`)
|
||||
return (
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center justify-between rounded-md px-3 py-1.5 text-[13px] transition-colors',
|
||||
active
|
||||
? 'bg-neutral-800/80 text-neutral-50'
|
||||
: 'text-neutral-400 hover:bg-neutral-900 hover:text-neutral-100',
|
||||
)}
|
||||
>
|
||||
<span>{item.label}</span>
|
||||
{item.badge && (
|
||||
<span className="rounded-full bg-neutral-800 px-1.5 py-0.5 text-[10px] font-medium text-neutral-300">
|
||||
{item.badge}
|
||||
</span>
|
||||
)}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
function SidebarFooter() {
|
||||
return (
|
||||
<div className="border-t border-neutral-900 px-4 py-3 text-[11px] text-neutral-500">
|
||||
<div>{brand.name} KMS</div>
|
||||
<a
|
||||
href="https://docs.hanzo.ai"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="text-neutral-400 hover:text-neutral-200"
|
||||
>
|
||||
docs.hanzo.ai
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface TopBarProps {
|
||||
title: string
|
||||
topRight?: ReactNode
|
||||
}
|
||||
|
||||
function TopBar({ topRight }: TopBarProps) {
|
||||
const [location] = useLocation()
|
||||
const crumbs = location.split('/').filter(Boolean)
|
||||
return (
|
||||
<header className="flex h-12 shrink-0 items-center justify-between border-b border-neutral-900 bg-neutral-950 px-4">
|
||||
<div className="flex items-center gap-2 text-[13px] text-neutral-400">
|
||||
<span>/</span>
|
||||
{crumbs.map((c, i) => (
|
||||
<span key={i} className="flex items-center gap-2">
|
||||
<span className="text-neutral-200">{decodeURIComponent(c)}</span>
|
||||
{i < crumbs.length - 1 && <span>/</span>}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">{topRight}</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function BrandMark() {
|
||||
return (
|
||||
<span
|
||||
className="inline-flex h-5 w-5 items-center justify-center rounded-[3px] font-mono text-[11px] font-bold text-white"
|
||||
style={{ background: 'var(--color-brand)' }}
|
||||
aria-hidden
|
||||
>
|
||||
H
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
import type { ButtonHTMLAttributes, ReactNode } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-offset-neutral-950 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary:
|
||||
'bg-[color:var(--color-brand)] text-white hover:bg-[color:var(--color-brand-hover)] focus:ring-[color:var(--color-brand)]',
|
||||
secondary:
|
||||
'border border-neutral-800 bg-neutral-900 text-neutral-100 hover:bg-neutral-800 focus:ring-neutral-700',
|
||||
danger:
|
||||
'bg-red-700 text-white hover:bg-red-800 focus:ring-red-700',
|
||||
ghost:
|
||||
'text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100 focus:ring-neutral-700',
|
||||
}
|
||||
|
||||
export function Button({ variant = 'secondary', className, children, ...rest }: ButtonProps) {
|
||||
return (
|
||||
<button {...rest} className={cn(base, variants[variant], className)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function Input({
|
||||
className,
|
||||
...rest
|
||||
}: React.InputHTMLAttributes<HTMLInputElement>) {
|
||||
return (
|
||||
<input
|
||||
{...rest}
|
||||
className={cn(
|
||||
'w-full rounded-md border border-neutral-800 bg-neutral-950 px-3 py-1.5 text-[13px] text-neutral-100 placeholder-neutral-500 focus:border-[color:var(--color-brand)] focus:outline-none focus:ring-1 focus:ring-[color:var(--color-brand)] disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function Label({
|
||||
children,
|
||||
htmlFor,
|
||||
}: {
|
||||
children: ReactNode
|
||||
htmlFor?: string
|
||||
}) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className="text-[12px] font-medium text-neutral-400">
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Badge({
|
||||
variant = 'neutral',
|
||||
children,
|
||||
}: {
|
||||
variant?: 'neutral' | 'success' | 'warn' | 'danger' | 'brand'
|
||||
children: ReactNode
|
||||
}) {
|
||||
const v = {
|
||||
neutral: 'bg-neutral-800 text-neutral-300',
|
||||
success: 'bg-emerald-900/60 text-emerald-200',
|
||||
warn: 'bg-amber-900/60 text-amber-200',
|
||||
danger: 'bg-red-900/60 text-red-200',
|
||||
brand: 'text-white',
|
||||
}[variant]
|
||||
const style =
|
||||
variant === 'brand' ? { background: 'var(--color-brand)' } : undefined
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium',
|
||||
v,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
export function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return (
|
||||
<div className={cn('rounded-lg border border-neutral-900 bg-neutral-950', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CodeBlock({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<pre className="overflow-auto rounded-md border border-neutral-900 bg-neutral-925/60 p-3 font-mono text-[12px] text-neutral-200">
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
@@ -1,166 +0,0 @@
|
||||
import { useMemo, type ReactNode } from 'react'
|
||||
|
||||
// CollectionCRUD — the one abstraction we keep. Renders a flat
|
||||
// collection: header, optional create row, error/empty/loading states,
|
||||
// a typed table.
|
||||
//
|
||||
// What it is NOT:
|
||||
// - It is not a god-component. It does not own data fetching, mutation,
|
||||
// URL state, or pagination. Pages decide all of that — this only
|
||||
// renders.
|
||||
// - It does not own row clicks. Pass `onRowClick` if you need them.
|
||||
// - It does not own filters. Render them as part of `headerExtra`.
|
||||
|
||||
export interface Column<T> {
|
||||
key: string
|
||||
header: string
|
||||
render: (row: T) => ReactNode
|
||||
className?: string
|
||||
width?: string | number
|
||||
}
|
||||
|
||||
export interface CollectionCRUDProps<T> {
|
||||
title: string
|
||||
description?: string
|
||||
rows: T[] | undefined
|
||||
columns: Column<T>[]
|
||||
rowKey: (row: T) => string
|
||||
loading?: boolean
|
||||
error?: string | null
|
||||
empty?: ReactNode
|
||||
headerExtra?: ReactNode
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
export function CollectionCRUD<T>(props: CollectionCRUDProps<T>) {
|
||||
const { title, description, rows, columns, rowKey, loading, error, empty, headerExtra, onRowClick } =
|
||||
props
|
||||
const isEmpty = !loading && !error && (!rows || rows.length === 0)
|
||||
return (
|
||||
<div className="flex flex-col gap-4 p-6">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-50">{title}</h1>
|
||||
{description && <p className="mt-0.5 text-[13px] text-neutral-400">{description}</p>}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">{headerExtra}</div>
|
||||
</header>
|
||||
|
||||
{error && <ErrorBanner message={error} />}
|
||||
|
||||
{loading && (
|
||||
<div className="rounded-lg border border-neutral-900 bg-neutral-950 px-4 py-8 text-center text-sm text-neutral-500">
|
||||
Loading…
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!loading && !error && !isEmpty && (
|
||||
<DataTable rows={rows!} columns={columns} rowKey={rowKey} onRowClick={onRowClick} />
|
||||
)}
|
||||
|
||||
{isEmpty && (empty || <EmptyBanner />)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface DataTableProps<T> {
|
||||
rows: T[]
|
||||
columns: Column<T>[]
|
||||
rowKey: (row: T) => string
|
||||
onRowClick?: (row: T) => void
|
||||
}
|
||||
|
||||
function DataTable<T>({ rows, columns, rowKey, onRowClick }: DataTableProps<T>) {
|
||||
return (
|
||||
<div className="overflow-x-auto rounded-lg border border-neutral-900">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="bg-neutral-925/40">
|
||||
<tr className="border-b border-neutral-900">
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
style={c.width ? { width: c.width } : undefined}
|
||||
className="px-3 py-2 text-[11px] font-medium uppercase tracking-wider text-neutral-500"
|
||||
>
|
||||
{c.header}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((row) => (
|
||||
<tr
|
||||
key={rowKey(row)}
|
||||
onClick={onRowClick ? () => onRowClick(row) : undefined}
|
||||
className={
|
||||
onRowClick
|
||||
? 'cursor-pointer border-b border-neutral-900 last:border-0 hover:bg-neutral-900/40'
|
||||
: 'border-b border-neutral-900 last:border-0'
|
||||
}
|
||||
>
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className={'px-3 py-2 text-neutral-200 ' + (c.className || '')}>
|
||||
{c.render(row)}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function ErrorBanner({ message }: { message: string }) {
|
||||
return (
|
||||
<div className="rounded-lg border border-red-900/60 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
{message}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function EmptyBanner() {
|
||||
return (
|
||||
<div className="rounded-lg border border-neutral-900 bg-neutral-950 px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-400">No records found.</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// useFilter — small helper for client-side filtering. Pages use it to
|
||||
// power the SearchInput → rows pipeline without each one re-inventing
|
||||
// the case-insensitive substring match.
|
||||
export function useFilter<T>(rows: T[] | undefined, query: string, fields: (keyof T)[]): T[] {
|
||||
return useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
if (!q || !rows) return rows || []
|
||||
return rows.filter((r) =>
|
||||
fields.some((f) => {
|
||||
const v = r[f]
|
||||
if (typeof v !== 'string') return false
|
||||
return v.toLowerCase().includes(q)
|
||||
}),
|
||||
)
|
||||
}, [rows, query, fields])
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = 'Search…',
|
||||
}: {
|
||||
value: string
|
||||
onChange: (v: string) => void
|
||||
placeholder?: string
|
||||
}) {
|
||||
return (
|
||||
<input
|
||||
type="search"
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
className="w-56 rounded-md border border-neutral-800 bg-neutral-950 px-3 py-1.5 text-[13px] text-neutral-100 placeholder-neutral-500 focus:border-[color:var(--color-brand)] focus:outline-none focus:ring-1 focus:ring-[color:var(--color-brand)]"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { ApiError } from '@/lib/api'
|
||||
import { signIn } from '@/lib/session'
|
||||
import { Button, Notice } from '@/components/ui'
|
||||
|
||||
/** A refusal, with the way back in when the session is what failed. */
|
||||
export function Failure({ error }: { error: Error }) {
|
||||
const signedOut = error instanceof ApiError && (error.status === 401 || /authentication required/i.test(error.message))
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<Notice>{signedOut ? 'Your session has ended.' : error.message}</Notice>
|
||||
{signedOut && (
|
||||
<Button variant="secondary" onClick={() => void signIn()}>
|
||||
Sign in again
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { Link, useLocation } from 'wouter'
|
||||
import { cn } from '@/lib/cn'
|
||||
import { Button } from '@/components/ui'
|
||||
|
||||
// The console chrome: a sidebar of pages, a top bar naming who is signed in,
|
||||
// and the page itself.
|
||||
|
||||
const pages = [
|
||||
{ href: '/', label: 'Secrets' },
|
||||
{ href: '/status', label: 'Status' },
|
||||
]
|
||||
|
||||
export interface ShellProps {
|
||||
product: string
|
||||
user: string
|
||||
onSignOut: () => void
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function Shell({ product, user, onSignOut, children }: ShellProps) {
|
||||
return (
|
||||
<div className="flex h-screen overflow-hidden bg-neutral-950 text-neutral-100">
|
||||
<aside className="flex w-56 shrink-0 flex-col border-r border-neutral-900">
|
||||
<Link href="/" className="flex items-center gap-2 px-4 py-4 text-sm font-semibold text-neutral-50">
|
||||
<img src="/favicon.svg" alt="" className="h-5 w-5" />
|
||||
<span>{product}</span>
|
||||
</Link>
|
||||
<nav className="flex flex-1 flex-col gap-px px-2">
|
||||
{pages.map((p) => (
|
||||
<NavLink key={p.href} href={p.href} label={p.label} />
|
||||
))}
|
||||
</nav>
|
||||
<a
|
||||
href="https://docs.hanzo.ai/docs/openapi/kms/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="border-t border-neutral-900 px-4 py-3 text-[12px] text-neutral-500 hover:text-neutral-200"
|
||||
>
|
||||
API reference
|
||||
</a>
|
||||
</aside>
|
||||
<div className="flex min-w-0 flex-1 flex-col">
|
||||
<header className="flex h-12 shrink-0 items-center justify-end gap-3 border-b border-neutral-900 px-4">
|
||||
{user && <span className="text-[12px] text-neutral-400">{user}</span>}
|
||||
<Button variant="ghost" onClick={onSignOut}>
|
||||
Sign out
|
||||
</Button>
|
||||
</header>
|
||||
<main className="flex-1 overflow-auto">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function NavLink({ href, label }: { href: string; label: string }) {
|
||||
const [location] = useLocation()
|
||||
const active = location === href
|
||||
return (
|
||||
<Link
|
||||
href={href}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-1.5 text-[13px] transition-colors',
|
||||
active ? 'bg-neutral-800/80 text-neutral-50' : 'text-neutral-400 hover:bg-neutral-900 hover:text-neutral-100',
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import type { ButtonHTMLAttributes, InputHTMLAttributes, ReactNode, TextareaHTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/cn'
|
||||
|
||||
export type ButtonVariant = 'primary' | 'secondary' | 'danger' | 'ghost'
|
||||
|
||||
export interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||
variant?: ButtonVariant
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
const base =
|
||||
'inline-flex items-center justify-center gap-2 rounded-md px-3 py-1.5 text-[13px] font-medium transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-neutral-600 disabled:cursor-not-allowed disabled:opacity-50'
|
||||
|
||||
const variants: Record<ButtonVariant, string> = {
|
||||
primary: 'bg-white text-black hover:bg-neutral-200',
|
||||
secondary: 'border border-neutral-800 bg-neutral-900 text-neutral-100 hover:bg-neutral-800',
|
||||
danger: 'border border-red-900/70 text-red-300 hover:bg-red-950/60',
|
||||
ghost: 'text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100',
|
||||
}
|
||||
|
||||
export function Button({ variant = 'secondary', className, children, type = 'button', ...rest }: ButtonProps) {
|
||||
return (
|
||||
<button {...rest} type={type} className={cn(base, variants[variant], className)}>
|
||||
{children}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
const field =
|
||||
'w-full rounded-md border border-neutral-800 bg-black px-3 py-1.5 text-[13px] text-neutral-100 placeholder-neutral-600 focus:border-neutral-500 focus:outline-none disabled:opacity-50'
|
||||
|
||||
export function Input({ className, ...rest }: InputHTMLAttributes<HTMLInputElement>) {
|
||||
return <input {...rest} className={cn(field, className)} />
|
||||
}
|
||||
|
||||
export function TextArea({ className, ...rest }: TextareaHTMLAttributes<HTMLTextAreaElement>) {
|
||||
return <textarea {...rest} className={cn(field, 'font-mono', className)} />
|
||||
}
|
||||
|
||||
export function Label({ children, htmlFor }: { children: ReactNode; htmlFor?: string }) {
|
||||
return (
|
||||
<label htmlFor={htmlFor} className="text-[12px] font-medium text-neutral-400">
|
||||
{children}
|
||||
</label>
|
||||
)
|
||||
}
|
||||
|
||||
export function Badge({ variant = 'neutral', children }: { variant?: 'neutral' | 'success' | 'danger'; children: ReactNode }) {
|
||||
const tone = {
|
||||
neutral: 'bg-neutral-900 text-neutral-300',
|
||||
success: 'bg-emerald-950 text-emerald-300',
|
||||
danger: 'bg-red-950 text-red-300',
|
||||
}[variant]
|
||||
return <span className={cn('inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium', tone)}>{children}</span>
|
||||
}
|
||||
|
||||
export function Card({ children, className }: { children: ReactNode; className?: string }) {
|
||||
return <div className={cn('rounded-lg border border-neutral-900 bg-neutral-950', className)}>{children}</div>
|
||||
}
|
||||
|
||||
export function CodeBlock({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<pre className="overflow-auto whitespace-pre-wrap break-all rounded-md border border-neutral-900 bg-black p-3 font-mono text-[12px] text-neutral-200">
|
||||
{children}
|
||||
</pre>
|
||||
)
|
||||
}
|
||||
|
||||
export function Notice({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<p className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-[12px] text-red-200" role="alert">
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
Vendored
-17
@@ -1,17 +0,0 @@
|
||||
/// <reference types="vite/client" />
|
||||
|
||||
// @hanzo/brand v1.3.0 ships JS without typings. We only need the runtime
|
||||
// brand config (brand.name, brand.title, etc.), so we declare a narrow
|
||||
// shape rather than `any`. When @hanzo/brand publishes typings we can
|
||||
// drop this and pick them up automatically.
|
||||
declare module '@hanzo/brand' {
|
||||
export interface HanzoBrandConfig {
|
||||
name: string
|
||||
title: string
|
||||
shortName: string
|
||||
appDomain: string
|
||||
docsDomain: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
export const brand: HanzoBrandConfig
|
||||
}
|
||||
+7
-28
@@ -1,17 +1,11 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
/* Hanzo brand surface. The @hanzo/brand exports the canonical palette
|
||||
(hanzoRed = #d81c33). The admin runs dark-only for now — the SPA is
|
||||
for operators, and the legacy frontend never had a useful light mode.
|
||||
Keep this tight; do not theme on top of Tailwind. */
|
||||
/* Monochrome on black, the Hanzo design tokens: white is the only accent, and
|
||||
red and green appear only as error and ready states. */
|
||||
|
||||
@theme {
|
||||
--color-brand: #d81c33;
|
||||
--color-brand-hover: #c01830;
|
||||
--color-brand-dim: #b5152b;
|
||||
--font-sans: 'Zen', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
|
||||
'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
--font-sans: 'Geist', ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||||
--font-mono: 'Geist Mono', ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
html,
|
||||
@@ -20,29 +14,14 @@ body,
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
height: 100%;
|
||||
background: #000;
|
||||
}
|
||||
|
||||
html {
|
||||
font-family: var(--font-sans);
|
||||
font-feature-settings: 'cv02', 'cv03', 'cv04', 'cv11';
|
||||
color-scheme: dark;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: rgba(216, 28, 54, 0.35);
|
||||
}
|
||||
|
||||
/* Scrollbar — minimal dark to match the admin chrome. */
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #262626;
|
||||
border-radius: 6px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #404040;
|
||||
background: rgba(255, 255, 255, 0.25);
|
||||
}
|
||||
|
||||
+52
-220
@@ -1,247 +1,79 @@
|
||||
// Thin fetch wrapper for /v1/kms/*. One way to talk to the KMS API.
|
||||
//
|
||||
// Auth: bearer token persisted in localStorage under KMS_TOKEN. The
|
||||
// /v1/kms/auth/login endpoint exchanges clientId + clientSecret for the
|
||||
// IAM access token; everything else carries that token as
|
||||
// `Authorization: Bearer <jwt>`. No cookies, no CSRF — purely
|
||||
// JWT-in-localStorage. The token lives in the operator's browser tab;
|
||||
// signing out clears it.
|
||||
// One way to talk to /v1/kms: same origin, with the signed-in session's bearer.
|
||||
|
||||
const TOKEN_KEY = 'KMS_TOKEN'
|
||||
const ORG_KEY = 'KMS_ORG'
|
||||
|
||||
export function getToken(): string | null {
|
||||
return window.localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function setToken(value: string | null): void {
|
||||
if (value) window.localStorage.setItem(TOKEN_KEY, value)
|
||||
else window.localStorage.removeItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
export function getOrg(): string {
|
||||
return window.localStorage.getItem(ORG_KEY) || 'hanzo'
|
||||
}
|
||||
|
||||
export function setOrg(value: string): void {
|
||||
window.localStorage.setItem(ORG_KEY, value)
|
||||
}
|
||||
import { getSession } from '@hanzo/iam/browser'
|
||||
import {
|
||||
base,
|
||||
draftBody,
|
||||
failure,
|
||||
listUrl,
|
||||
secretUrl,
|
||||
type Config,
|
||||
type Draft,
|
||||
type Health,
|
||||
type Listing,
|
||||
type Opened,
|
||||
type SecretMeta,
|
||||
} from './kms'
|
||||
|
||||
export class ApiError extends Error {
|
||||
status: number
|
||||
body: unknown
|
||||
constructor(status: number, message: string, body?: unknown) {
|
||||
readonly status: number
|
||||
constructor(status: number, message: string) {
|
||||
super(message)
|
||||
this.status = status
|
||||
this.body = body
|
||||
this.name = 'ApiError'
|
||||
this.status = status
|
||||
}
|
||||
}
|
||||
|
||||
interface ApiOptions {
|
||||
method?: 'GET' | 'POST' | 'PATCH' | 'DELETE'
|
||||
body?: unknown
|
||||
headers?: Record<string, string>
|
||||
signal?: AbortSignal
|
||||
}
|
||||
|
||||
export async function api<T>(path: string, opts: ApiOptions = {}): Promise<T> {
|
||||
const headers: Record<string, string> = {
|
||||
Accept: 'application/json',
|
||||
...opts.headers,
|
||||
}
|
||||
const token = getToken()
|
||||
if (token) headers.Authorization = `Bearer ${token}`
|
||||
let body: BodyInit | undefined
|
||||
if (opts.body !== undefined) {
|
||||
headers['Content-Type'] = 'application/json'
|
||||
body = JSON.stringify(opts.body)
|
||||
}
|
||||
const res = await fetch(path, {
|
||||
method: opts.method || 'GET',
|
||||
headers,
|
||||
body,
|
||||
signal: opts.signal,
|
||||
})
|
||||
async function send(url: string, init: RequestInit = {}): Promise<{ status: number; body: unknown }> {
|
||||
const headers = new Headers(init.headers)
|
||||
headers.set('Accept', 'application/json')
|
||||
if (init.body) headers.set('Content-Type', 'application/json')
|
||||
const token = getSession().accessToken
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`)
|
||||
const res = await fetch(url, { ...init, headers })
|
||||
const text = await res.text()
|
||||
let parsed: unknown
|
||||
let body: unknown = text
|
||||
try {
|
||||
parsed = text ? JSON.parse(text) : null
|
||||
body = text ? JSON.parse(text) : null
|
||||
} catch {
|
||||
parsed = text
|
||||
// A body that is not JSON is carried as text.
|
||||
}
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
(parsed && typeof parsed === 'object' && 'message' in parsed
|
||||
? String((parsed as Record<string, unknown>).message)
|
||||
: null) || res.statusText || `HTTP ${res.status}`
|
||||
throw new ApiError(res.status, msg, parsed)
|
||||
}
|
||||
return parsed as T
|
||||
return { status: res.status, body }
|
||||
}
|
||||
|
||||
// --- Typed endpoints --------------------------------------------------
|
||||
|
||||
export interface LoginResponse {
|
||||
accessToken: string
|
||||
expiresIn: number
|
||||
tokenType: string
|
||||
async function call<T>(url: string, init?: RequestInit): Promise<T> {
|
||||
const { status, body } = await send(url, init)
|
||||
if (status < 200 || status > 299) throw new ApiError(status, failure(status, body))
|
||||
return body as T
|
||||
}
|
||||
|
||||
export async function login(clientId: string, clientSecret: string): Promise<LoginResponse> {
|
||||
return api<LoginResponse>('/v1/kms/auth/login', {
|
||||
method: 'POST',
|
||||
body: { clientId, clientSecret },
|
||||
})
|
||||
/** Public, fetched before sign-in, so it carries no bearer. */
|
||||
export async function fetchConfig(): Promise<Config> {
|
||||
const res = await fetch(`${base}/config`, { headers: { Accept: 'application/json' } })
|
||||
const body: unknown = await res.json().catch(() => null)
|
||||
if (!res.ok) throw new ApiError(res.status, failure(res.status, body))
|
||||
return body as Config
|
||||
}
|
||||
|
||||
export interface HealthResponse {
|
||||
status: string
|
||||
service: string
|
||||
version: string
|
||||
/** Readiness answers 503 with the same body when it is not ready, so both are data. */
|
||||
export async function fetchHealth(): Promise<Health> {
|
||||
const { status, body } = await send(`${base}/health`)
|
||||
if (status === 200 || status === 503) return body as Health
|
||||
throw new ApiError(status, failure(status, body))
|
||||
}
|
||||
|
||||
export function fetchHealth(): Promise<HealthResponse> {
|
||||
return api<HealthResponse>('/v1/kms/health')
|
||||
export function listSecrets(path: string, env: string): Promise<Listing> {
|
||||
return call<Listing>(listUrl(path, env))
|
||||
}
|
||||
|
||||
// --- Secrets ----------------------------------------------------------
|
||||
|
||||
export interface SecretValue {
|
||||
secret: { value: string }
|
||||
version: number
|
||||
export function openSecret(s: SecretMeta): Promise<Opened> {
|
||||
return call<Opened>(secretUrl(s.path, s.name, s.env))
|
||||
}
|
||||
|
||||
export function getSecret(
|
||||
org: string,
|
||||
path: string,
|
||||
name: string,
|
||||
env: string,
|
||||
): Promise<SecretValue> {
|
||||
const u = secretUrl(org, path, name, env)
|
||||
return api<SecretValue>(u)
|
||||
export function putSecret(d: Draft): Promise<unknown> {
|
||||
return call(`${base}/secrets`, { method: 'POST', body: draftBody(d) })
|
||||
}
|
||||
|
||||
export interface PutSecretBody {
|
||||
path: string
|
||||
name: string
|
||||
env: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export function putSecret(
|
||||
org: string,
|
||||
body: PutSecretBody,
|
||||
): Promise<{ ok: boolean; version: number }> {
|
||||
return api(`/v1/kms/orgs/${encodeURIComponent(org)}/secrets`, {
|
||||
method: 'POST',
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
export interface PatchSecretBody {
|
||||
value: string
|
||||
version: number
|
||||
env?: string
|
||||
}
|
||||
|
||||
export function patchSecret(
|
||||
org: string,
|
||||
path: string,
|
||||
name: string,
|
||||
body: PatchSecretBody,
|
||||
): Promise<{ ok: boolean; version: number }> {
|
||||
const u = `/v1/kms/orgs/${encodeURIComponent(org)}/secrets/${encodePath(path, name)}`
|
||||
return api(u, {
|
||||
method: 'PATCH',
|
||||
body,
|
||||
headers: { 'If-Match': String(body.version) },
|
||||
})
|
||||
}
|
||||
|
||||
export function deleteSecret(
|
||||
org: string,
|
||||
path: string,
|
||||
name: string,
|
||||
env: string,
|
||||
): Promise<{ ok: boolean }> {
|
||||
const u = secretUrl(org, path, name, env)
|
||||
return api(u, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
function secretUrl(org: string, path: string, name: string, env: string): string {
|
||||
const qs = env ? `?env=${encodeURIComponent(env)}` : ''
|
||||
return `/v1/kms/orgs/${encodeURIComponent(org)}/secrets/${encodePath(path, name)}${qs}`
|
||||
}
|
||||
|
||||
function encodePath(p: string, name: string): string {
|
||||
// /a/b/c + name → "a/b/c/name" with each segment encoded. Strip
|
||||
// leading/trailing slashes and empty segments so user input is
|
||||
// tolerated.
|
||||
const parts = `${p}/${name}`.split('/').filter(Boolean)
|
||||
return parts.map(encodeURIComponent).join('/')
|
||||
}
|
||||
|
||||
// --- MPC keys ---------------------------------------------------------
|
||||
|
||||
export interface ValidatorKeySet {
|
||||
id: string
|
||||
validator_id?: string
|
||||
threshold?: number
|
||||
parties?: number
|
||||
bls_public_key?: string
|
||||
corona_public_key?: string
|
||||
created_at?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
export function listKeys(): Promise<ValidatorKeySet[]> {
|
||||
return api<ValidatorKeySet[]>('/v1/kms/keys')
|
||||
}
|
||||
|
||||
export function getKey(id: string): Promise<ValidatorKeySet> {
|
||||
return api<ValidatorKeySet>(`/v1/kms/keys/${encodeURIComponent(id)}`)
|
||||
}
|
||||
|
||||
export interface KMSStatus {
|
||||
kms: string
|
||||
mpc: unknown
|
||||
details?: string
|
||||
}
|
||||
|
||||
export function fetchStatus(): Promise<KMSStatus> {
|
||||
return api<KMSStatus>('/v1/kms/status')
|
||||
}
|
||||
|
||||
export interface AuditStats {
|
||||
written: number
|
||||
dropped: number
|
||||
}
|
||||
|
||||
export function fetchAuditStats(): Promise<AuditStats> {
|
||||
return api<AuditStats>('/v1/kms/audit/stats')
|
||||
}
|
||||
|
||||
// --- Token claim helpers (no signature verify; UI hint only) ----------
|
||||
|
||||
export interface TokenClaims {
|
||||
sub?: string
|
||||
owner?: string
|
||||
iss?: string
|
||||
aud?: string | string[]
|
||||
exp?: number
|
||||
roles?: string[]
|
||||
email?: string
|
||||
}
|
||||
|
||||
export function decodeToken(token: string | null): TokenClaims | null {
|
||||
if (!token) return null
|
||||
const parts = token.split('.')
|
||||
if (parts.length < 2) return null
|
||||
try {
|
||||
const json = atob(parts[1].replace(/-/g, '+').replace(/_/g, '/'))
|
||||
return JSON.parse(json)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
export function deleteSecret(s: SecretMeta): Promise<unknown> {
|
||||
return call(secretUrl(s.path, s.name, s.env), { method: 'DELETE' })
|
||||
}
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { strict as assert } from 'node:assert'
|
||||
import { describe, it } from 'node:test'
|
||||
import { cleanPath, clientId, draftBody, failure, listUrl, secretUrl, subject } from './kms.ts'
|
||||
|
||||
describe('addresses', () => {
|
||||
it('names the IAM client <org>-kms', () => {
|
||||
assert.equal(clientId('hanzo'), 'hanzo-kms')
|
||||
})
|
||||
|
||||
it('stores paths as /a/b, with the org root empty', () => {
|
||||
assert.equal(cleanPath(''), '')
|
||||
assert.equal(cleanPath('/'), '')
|
||||
assert.equal(cleanPath('ci'), '/ci')
|
||||
assert.equal(cleanPath(' /ci//deploy/ '), '/ci/deploy')
|
||||
})
|
||||
|
||||
it('lists the whole org when no filter is given', () => {
|
||||
assert.equal(listUrl('', ''), '/v1/kms/secrets')
|
||||
assert.equal(listUrl('ci', ' prod '), '/v1/kms/secrets?path=%2Fci&env=prod')
|
||||
})
|
||||
|
||||
it('addresses one secret by path segments and name, never by org', () => {
|
||||
assert.equal(secretUrl('', 'TOKEN', 'prod'), '/v1/kms/secrets/TOKEN?env=prod')
|
||||
assert.equal(secretUrl('/ci/deploy', 'NPM TOKEN', 'dev'), '/v1/kms/secrets/ci/deploy/NPM%20TOKEN?env=dev')
|
||||
assert.ok(!secretUrl('/ci', 'X', 'prod').includes('/orgs/'))
|
||||
})
|
||||
|
||||
it('writes the path in stored form and keeps the value as typed', () => {
|
||||
assert.deepEqual(JSON.parse(draftBody({ path: 'ci/', name: ' A ', env: ' prod', value: ' v ' })), {
|
||||
path: '/ci',
|
||||
name: 'A',
|
||||
env: 'prod',
|
||||
value: ' v ',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('responses', () => {
|
||||
it('reads the problem detail first', () => {
|
||||
assert.equal(failure(403, { title: 'Forbidden', detail: 'authentication required' }), 'authentication required')
|
||||
assert.equal(failure(502, { message: 'kms login failed' }), 'kms login failed')
|
||||
assert.equal(failure(404, 'not found\n'), 'not found')
|
||||
assert.equal(failure(500, null), 'HTTP 500')
|
||||
})
|
||||
|
||||
it('names the signed-in subject from the token payload', () => {
|
||||
const payload = btoa(JSON.stringify({ sub: 'u1', email: '[email protected]' })).replace(/=+$/, '')
|
||||
assert.equal(subject(`h.${payload}.s`), '[email protected]')
|
||||
assert.equal(subject(null), '')
|
||||
assert.equal(subject('not-a-jwt'), '')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
// The KMS surface the console calls, as types and addresses. cloud serves all of
|
||||
// it under /v1/kms, and the org is the caller's, read from the token: no address
|
||||
// here names one.
|
||||
|
||||
export const base = '/v1/kms'
|
||||
|
||||
/** GET /v1/kms/config: what the console needs before anyone signs in. */
|
||||
export interface Config {
|
||||
apiBase: string
|
||||
brand: string
|
||||
issuer: string
|
||||
loginPath: string
|
||||
}
|
||||
|
||||
/** GET /v1/kms/health: the same body at 200 (ready) and 503 (not ready). */
|
||||
export interface Health {
|
||||
ready: boolean
|
||||
service: string
|
||||
status: string
|
||||
signing?: boolean
|
||||
error?: string
|
||||
}
|
||||
|
||||
/** One secret's descriptor. A listing never carries a value. */
|
||||
export interface SecretMeta {
|
||||
name: string
|
||||
path: string
|
||||
env: string
|
||||
scheme: string
|
||||
}
|
||||
|
||||
export interface Listing {
|
||||
names: string[]
|
||||
secrets: SecretMeta[]
|
||||
total: number
|
||||
}
|
||||
|
||||
export interface Opened {
|
||||
env: string
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export interface Draft {
|
||||
path: string
|
||||
name: string
|
||||
env: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** The IAM client a brand's console signs in through, named <org>-<app>. */
|
||||
export function clientId(brand: string): string {
|
||||
return `${brand}-kms`
|
||||
}
|
||||
|
||||
/** A path as the API stores it: `/a/b`, or empty for the org root. */
|
||||
export function cleanPath(path: string): string {
|
||||
const parts = path
|
||||
.split('/')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
return parts.length ? `/${parts.join('/')}` : ''
|
||||
}
|
||||
|
||||
/** The listing of a subtree in one environment. Empty values mean all of it. */
|
||||
export function listUrl(path: string, env: string): string {
|
||||
const q = new URLSearchParams()
|
||||
const p = cleanPath(path)
|
||||
if (p) q.set('path', p)
|
||||
const e = env.trim()
|
||||
if (e) q.set('env', e)
|
||||
const s = q.toString()
|
||||
return s ? `${base}/secrets?${s}` : `${base}/secrets`
|
||||
}
|
||||
|
||||
/** One secret's address: its path segments, then its name, each encoded. */
|
||||
export function secretUrl(path: string, name: string, env: string): string {
|
||||
const segments = [...cleanPath(path).split('/').filter(Boolean), name.trim()]
|
||||
const tail = segments.map(encodeURIComponent).join('/')
|
||||
return `${base}/secrets/${tail}?env=${encodeURIComponent(env.trim())}`
|
||||
}
|
||||
|
||||
/** The body of a write, with the path in the stored form. */
|
||||
export function draftBody(d: Draft): string {
|
||||
return JSON.stringify({
|
||||
path: cleanPath(d.path),
|
||||
name: d.name.trim(),
|
||||
env: d.env.trim(),
|
||||
value: d.value,
|
||||
})
|
||||
}
|
||||
|
||||
/** What a refused response says: the problem detail, a message, else the status. */
|
||||
export function failure(status: number, body: unknown): string {
|
||||
if (body && typeof body === 'object') {
|
||||
const b = body as Record<string, unknown>
|
||||
for (const key of ['detail', 'message', 'title']) {
|
||||
if (typeof b[key] === 'string' && b[key]) return b[key] as string
|
||||
}
|
||||
}
|
||||
if (typeof body === 'string' && body.trim()) return body.trim()
|
||||
return `HTTP ${status}`
|
||||
}
|
||||
|
||||
/** Who a token names, for display. The server verifies it; this only reads it. */
|
||||
export function subject(token: string | null): string {
|
||||
const payload = token?.split('.')[1]
|
||||
if (!payload) return ''
|
||||
try {
|
||||
const json = atob(payload.replace(/-/g, '+').replace(/_/g, '/'))
|
||||
const claims = JSON.parse(json) as Record<string, unknown>
|
||||
for (const key of ['email', 'preferred_username', 'name', 'sub']) {
|
||||
if (typeof claims[key] === 'string' && claims[key]) return claims[key] as string
|
||||
}
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
return ''
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
// Sign-in is Hanzo IAM: authorization code with PKCE against the issuer the
|
||||
// server names in /v1/kms/config, as the brand's `<org>-kms` client.
|
||||
|
||||
import { configureIam, getSession, handleCallback, logout, startLogin } from '@hanzo/iam/browser'
|
||||
import { clientId, subject, type Config } from './kms'
|
||||
|
||||
export const callbackPath = '/auth/callback'
|
||||
|
||||
export function configure(config: Config): void {
|
||||
configureIam({
|
||||
issuer: config.issuer,
|
||||
clientId: clientId(config.brand),
|
||||
redirect: `${window.location.origin}${callbackPath}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Finishes a sign-in that returned to the callback path and says where to go next. */
|
||||
export async function finishSignIn(): Promise<string> {
|
||||
const { redirect } = await handleCallback()
|
||||
return redirect.startsWith('/') && !redirect.startsWith('//') ? redirect : '/'
|
||||
}
|
||||
|
||||
export function signIn(): Promise<void> {
|
||||
const here = `${window.location.pathname}${window.location.search}`
|
||||
return startLogin({ redirect: here === callbackPath ? '/' : here })
|
||||
}
|
||||
|
||||
export function signOut(): Promise<void> {
|
||||
return logout()
|
||||
}
|
||||
|
||||
export function signedIn(): boolean {
|
||||
return getSession().authenticated
|
||||
}
|
||||
|
||||
export function who(): string {
|
||||
return subject(getSession().accessToken)
|
||||
}
|
||||
@@ -1,87 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchAuditStats } from '@/lib/api'
|
||||
import { Card } from '@/components/Button'
|
||||
|
||||
// Audit — read-only. The canonical /v1/kms/audit/stats only exposes the
|
||||
// background-writer counters (written + dropped). Per-event tailing is
|
||||
// not on the public surface; the audit ledger is a side-table SQLite at
|
||||
// $KMS_AUDIT_DB that operators inspect on the host. This page surfaces
|
||||
// what's available and signposts the rest.
|
||||
|
||||
export function AuditPage() {
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['audit-stats'],
|
||||
queryFn: fetchAuditStats,
|
||||
refetchInterval: 5000,
|
||||
retry: false,
|
||||
})
|
||||
const errMsg =
|
||||
error instanceof Error
|
||||
? error.message.includes('admin role')
|
||||
? 'Admin role required.'
|
||||
: error.message
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<header className="mb-4">
|
||||
<h1 className="text-lg font-semibold text-neutral-50">Audit</h1>
|
||||
<p className="text-[13px] text-neutral-400">
|
||||
Background-writer counters from <code>/v1/kms/audit/stats</code>. Every secret read, write,
|
||||
rotate, and delete is recorded to a host-side SQLite ledger; per-event tailing is operator
|
||||
tooling, not a UI surface.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{errMsg ? (
|
||||
<div className="rounded-lg border border-red-900/60 bg-red-950/40 px-4 py-3 text-sm text-red-200">
|
||||
{errMsg}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<StatCard label="Entries written" value={isLoading ? '…' : data?.written ?? 0} />
|
||||
<StatCard
|
||||
label="Entries dropped"
|
||||
value={isLoading ? '…' : data?.dropped ?? 0}
|
||||
highlight={data?.dropped ? data.dropped > 0 : false}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Card className="mt-6 p-4 text-[12px] text-neutral-400">
|
||||
<h2 className="mb-2 text-sm font-semibold text-neutral-200">How to read the ledger</h2>
|
||||
<p>
|
||||
The audit ledger lives at the path configured by <code>KMS_AUDIT_DB</code> (default{' '}
|
||||
<code>/tmp/kms-aux.db</code>). It is append-only, buffered, and never blocks the request
|
||||
path. To inspect entries, exec into the pod and use the <code>kms</code> CLI:
|
||||
</p>
|
||||
<pre className="mt-2 overflow-auto rounded-md border border-neutral-900 bg-neutral-925/60 p-3 font-mono text-[12px] text-neutral-200">
|
||||
{`kubectl exec deploy/kms -- /usr/local/bin/kms audit tail --since 1h`}
|
||||
</pre>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
label,
|
||||
value,
|
||||
highlight,
|
||||
}: {
|
||||
label: string
|
||||
value: number | string
|
||||
highlight?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card className="p-5">
|
||||
<div className="text-[11px] font-medium uppercase tracking-wider text-neutral-500">{label}</div>
|
||||
<div
|
||||
className={
|
||||
'mt-1 text-3xl font-semibold ' + (highlight ? 'text-red-400' : 'text-neutral-50')
|
||||
}
|
||||
>
|
||||
{value}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -1,41 +0,0 @@
|
||||
import { Card, CodeBlock } from '@/components/Button'
|
||||
|
||||
// EndpointGap — the honest signpost. Several admin pages in the task
|
||||
// list (projects, workspaces, identities, integrations, certificates)
|
||||
// have NO backing endpoints on the canonical /v1/kms/* surface. This
|
||||
// page surfaces that explicitly so operators don't think it's a UI
|
||||
// regression — and so the next iteration of the backend has a checklist
|
||||
// of what to add. One renderer; the route table parameterises the page.
|
||||
|
||||
export interface EndpointGapProps {
|
||||
title: string
|
||||
description: string
|
||||
endpoints: string[]
|
||||
rationale?: string
|
||||
}
|
||||
|
||||
export function EndpointGap({ title, description, endpoints, rationale }: EndpointGapProps) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<header className="mb-4">
|
||||
<h1 className="text-lg font-semibold text-neutral-50">{title}</h1>
|
||||
<p className="text-[13px] text-neutral-400">{description}</p>
|
||||
</header>
|
||||
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-2 text-sm font-semibold text-amber-300">No backing endpoints</h2>
|
||||
<p className="text-[12px] text-neutral-400">
|
||||
The canonical KMS surface (HIP-0027) does not expose a {title.toLowerCase()} resource. This
|
||||
page is a stub so operators can find the documentation; the backend will need to add the
|
||||
routes below before the UI can render real data.
|
||||
</p>
|
||||
<div className="mt-3 flex flex-col gap-2">
|
||||
{endpoints.map((e) => (
|
||||
<CodeBlock key={e}>{e}</CodeBlock>
|
||||
))}
|
||||
</div>
|
||||
{rationale && <p className="mt-3 text-[12px] text-neutral-400">{rationale}</p>}
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { listKeys, type ValidatorKeySet } from '@/lib/api'
|
||||
import { CollectionCRUD, SearchInput, useFilter, type Column } from '@/components/CollectionCRUD'
|
||||
import { Badge, CodeBlock } from '@/components/Button'
|
||||
|
||||
// MPC keys — the one resource with a real list endpoint in the canonical
|
||||
// surface (/v1/kms/keys, admin role required). Renders flat via
|
||||
// CollectionCRUD. Row click expands the full key set into the side
|
||||
// panel.
|
||||
|
||||
const columns: Column<ValidatorKeySet>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
header: 'ID',
|
||||
render: (r) => <code className="text-[12px] text-neutral-200">{r.id}</code>,
|
||||
},
|
||||
{
|
||||
key: 'validator',
|
||||
header: 'Validator',
|
||||
render: (r) => <code className="text-[12px] text-neutral-200">{r.validator_id || '—'}</code>,
|
||||
},
|
||||
{
|
||||
key: 'threshold',
|
||||
header: 'Threshold',
|
||||
render: (r) =>
|
||||
r.threshold && r.parties ? `${r.threshold} of ${r.parties}` : '—',
|
||||
},
|
||||
{
|
||||
key: 'created',
|
||||
header: 'Created',
|
||||
render: (r) => <span className="text-[12px] text-neutral-400">{r.created_at || '—'}</span>,
|
||||
},
|
||||
]
|
||||
|
||||
export function KeysPage() {
|
||||
const [query, setQuery] = useState('')
|
||||
const [open, setOpen] = useState<ValidatorKeySet | null>(null)
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['keys'],
|
||||
queryFn: listKeys,
|
||||
retry: false,
|
||||
})
|
||||
const filtered = useFilter(data, query, ['id', 'validator_id'])
|
||||
const errMsg =
|
||||
error instanceof Error
|
||||
? error.message.includes('admin role')
|
||||
? 'Admin role required to list keys.'
|
||||
: error.message
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<CollectionCRUD<ValidatorKeySet>
|
||||
title="MPC Keys"
|
||||
description="Threshold-signing key sets managed via the MPC daemon. Admin role required."
|
||||
rows={filtered}
|
||||
columns={columns}
|
||||
rowKey={(r) => r.id}
|
||||
loading={isLoading}
|
||||
error={errMsg}
|
||||
onRowClick={(r) => setOpen(r)}
|
||||
headerExtra={<SearchInput value={query} onChange={setQuery} placeholder="Filter…" />}
|
||||
empty={
|
||||
<div className="rounded-lg border border-neutral-900 bg-neutral-950 px-4 py-12 text-center">
|
||||
<p className="text-sm text-neutral-300">No key sets yet.</p>
|
||||
<p className="mt-1 text-[12px] text-neutral-500">
|
||||
MPC keys are generated via{' '}
|
||||
<code>POST /v1/kms/keys/generate</code> — provide validator_id, threshold, parties.
|
||||
</p>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
{open && <KeyDetail item={open} onClose={() => setOpen(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function KeyDetail({ item, onClose }: { item: ValidatorKeySet; onClose: () => void }) {
|
||||
return (
|
||||
<aside className="w-96 shrink-0 overflow-y-auto border-l border-neutral-900 bg-neutral-950 p-5">
|
||||
<header className="mb-4 flex items-start justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge variant="brand">key set</Badge>
|
||||
{item.threshold && item.parties && (
|
||||
<Badge>{`${item.threshold} of ${item.parties}`}</Badge>
|
||||
)}
|
||||
</div>
|
||||
<h2 className="mt-2 break-all text-sm font-semibold text-neutral-50">{item.id}</h2>
|
||||
</div>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="text-[20px] leading-none text-neutral-500 hover:text-neutral-200"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</header>
|
||||
<pre className="overflow-auto rounded-md border border-neutral-900 bg-neutral-925/60 p-3 font-mono text-[11px] text-neutral-200">
|
||||
{JSON.stringify(item, null, 2)}
|
||||
</pre>
|
||||
<p className="mt-3 text-[11px] text-neutral-500">
|
||||
Sign and rotate calls live at <code>/v1/kms/keys/{id}/sign</code> and{' '}
|
||||
<code>/v1/kms/keys/{id}/rotate</code>. Use the <code>kms</code> CLI for those —
|
||||
signing payloads should not pass through a browser session.
|
||||
</p>
|
||||
{(item.bls_public_key || item.corona_public_key) && (
|
||||
<div className="mt-4 flex flex-col gap-2">
|
||||
{item.bls_public_key && (
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-neutral-400">BLS slot public key (secp256k1 / CGGMP21 — not bls12-381)</div>
|
||||
<CodeBlock>{String(item.bls_public_key)}</CodeBlock>
|
||||
</div>
|
||||
)}
|
||||
{item.corona_public_key && (
|
||||
<div>
|
||||
<div className="text-[11px] font-medium text-neutral-400">Corona slot public key (ed25519 / FROST — not post-quantum)</div>
|
||||
<CodeBlock>{String(item.corona_public_key)}</CodeBlock>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
@@ -1,104 +0,0 @@
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { ApiError, login, setOrg, setToken } from '@/lib/api'
|
||||
import { Button, Card, Input, Label } from '@/components/Button'
|
||||
|
||||
// Login — exchanges clientId + clientSecret for an IAM access token via
|
||||
// /v1/kms/auth/login. Token is stored in localStorage; the org is
|
||||
// remembered separately because the API path includes {org}.
|
||||
|
||||
export function LoginPage({ onSuccess }: { onSuccess: () => void }) {
|
||||
const [clientId, setClientId] = useState('')
|
||||
const [clientSecret, setClientSecret] = useState('')
|
||||
const [org, setOrgInput] = useState('hanzo')
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [busy, setBusy] = useState(false)
|
||||
|
||||
async function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setBusy(true)
|
||||
setError(null)
|
||||
try {
|
||||
const { accessToken } = await login(clientId.trim(), clientSecret.trim())
|
||||
setToken(accessToken)
|
||||
setOrg(org.trim() || 'hanzo')
|
||||
onSuccess()
|
||||
} catch (err) {
|
||||
const msg = err instanceof ApiError ? err.message : (err as Error).message
|
||||
setError(msg || 'login failed')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-neutral-950 p-6">
|
||||
<Card className="w-full max-w-sm p-6">
|
||||
<div className="mb-6 flex items-center gap-2">
|
||||
<span
|
||||
className="inline-flex h-7 w-7 items-center justify-center rounded-[4px] font-mono text-[13px] font-bold text-white"
|
||||
style={{ background: 'var(--color-brand)' }}
|
||||
aria-hidden
|
||||
>
|
||||
H
|
||||
</span>
|
||||
<div>
|
||||
<div className="text-base font-semibold text-neutral-50">Hanzo KMS</div>
|
||||
<div className="text-[11px] text-neutral-500">sign in with a machine identity</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="org">Organization</Label>
|
||||
<Input
|
||||
id="org"
|
||||
type="text"
|
||||
autoComplete="off"
|
||||
value={org}
|
||||
onChange={(e) => setOrgInput(e.target.value)}
|
||||
placeholder="hanzo"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="clientId">Client ID</Label>
|
||||
<Input
|
||||
id="clientId"
|
||||
type="text"
|
||||
autoComplete="username"
|
||||
value={clientId}
|
||||
onChange={(e) => setClientId(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor="clientSecret">Client Secret</Label>
|
||||
<Input
|
||||
id="clientSecret"
|
||||
type="password"
|
||||
autoComplete="current-password"
|
||||
value={clientSecret}
|
||||
onChange={(e) => setClientSecret(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<p className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-[12px] text-red-200">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Button type="submit" variant="primary" disabled={busy}>
|
||||
{busy ? 'Signing in…' : 'Sign in'}
|
||||
</Button>
|
||||
</form>
|
||||
|
||||
<p className="mt-4 text-[11px] text-neutral-500">
|
||||
Credentials are exchanged at <code className="text-neutral-300">/v1/kms/auth/login</code> for
|
||||
an IAM access token. The token lives in localStorage.
|
||||
</p>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+193
-520
@@ -1,554 +1,227 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from 'react'
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import {
|
||||
ApiError,
|
||||
deleteSecret,
|
||||
getOrg,
|
||||
getSecret,
|
||||
patchSecret,
|
||||
putSecret,
|
||||
} from '@/lib/api'
|
||||
import { Badge, Button, Card, CodeBlock, Input, Label } from '@/components/Button'
|
||||
import { deleteSecret, listSecrets, openSecret, putSecret } from '@/lib/api'
|
||||
import type { Draft, SecretMeta } from '@/lib/kms'
|
||||
import { Badge, Button, Card, CodeBlock, Input, Label, TextArea } from '@/components/ui'
|
||||
import { Failure } from '@/components/Failure'
|
||||
|
||||
// Secrets — bespoke browser, NOT a flat CollectionCRUD.
|
||||
//
|
||||
// The canonical /v1/kms/* surface has NO list endpoint for secrets — it
|
||||
// is strictly path-addressed CRUD. So the "tree view" is a client-side
|
||||
// breadcrumb of paths the operator has visited in this session, plus a
|
||||
// direct lookup by (path, name, env).
|
||||
//
|
||||
// LocalStorage stores the visited paths so reloads don't lose the
|
||||
// browsing context. Nothing else is cached — value lookups always hit
|
||||
// the server.
|
||||
|
||||
const HISTORY_KEY = 'KMS_SECRET_HISTORY'
|
||||
|
||||
interface SecretRef {
|
||||
path: string
|
||||
name: string
|
||||
env: string
|
||||
}
|
||||
|
||||
function loadHistory(): SecretRef[] {
|
||||
try {
|
||||
const raw = window.localStorage.getItem(HISTORY_KEY)
|
||||
if (!raw) return []
|
||||
const v = JSON.parse(raw)
|
||||
if (!Array.isArray(v)) return []
|
||||
return v.filter(
|
||||
(r): r is SecretRef =>
|
||||
typeof r === 'object' &&
|
||||
typeof r.path === 'string' &&
|
||||
typeof r.name === 'string' &&
|
||||
typeof r.env === 'string',
|
||||
)
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
function saveHistory(refs: SecretRef[]): void {
|
||||
window.localStorage.setItem(HISTORY_KEY, JSON.stringify(refs.slice(0, 50)))
|
||||
}
|
||||
|
||||
function refKey(r: SecretRef): string {
|
||||
return `${r.env}|${r.path}|${r.name}`
|
||||
}
|
||||
// Secrets: the organization's listing from GET /v1/kms/secrets, narrowed by path
|
||||
// and environment. A value is fetched only when a row is opened, and written or
|
||||
// removed one secret at a time.
|
||||
|
||||
export function SecretsPage() {
|
||||
const [history, setHistory] = useState<SecretRef[]>(() => loadHistory())
|
||||
const [selected, setSelected] = useState<SecretRef | null>(null)
|
||||
const qc = useQueryClient()
|
||||
const [path, setPath] = useState('')
|
||||
const [env, setEnv] = useState('')
|
||||
const [filter, setFilter] = useState({ path: '', env: '' })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
saveHistory(history)
|
||||
}, [history])
|
||||
const list = useQuery({
|
||||
queryKey: ['secrets', filter.path, filter.env],
|
||||
queryFn: () => listSecrets(filter.path, filter.env),
|
||||
})
|
||||
|
||||
function record(ref: SecretRef) {
|
||||
setHistory((prev) => {
|
||||
const next = [ref, ...prev.filter((r) => refKey(r) !== refKey(ref))]
|
||||
return next
|
||||
})
|
||||
const refresh = () => qc.invalidateQueries({ queryKey: ['secrets'] })
|
||||
|
||||
function apply(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setFilter({ path, env })
|
||||
}
|
||||
|
||||
function forget(ref: SecretRef) {
|
||||
setHistory((prev) => prev.filter((r) => refKey(r) !== refKey(ref)))
|
||||
}
|
||||
|
||||
const tree = useMemo(() => buildTree(history), [history])
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0">
|
||||
<aside className="w-72 shrink-0 overflow-y-auto border-r border-neutral-900 bg-neutral-950 p-3">
|
||||
<div className="px-1 pb-2 text-[11px] font-semibold uppercase tracking-wider text-neutral-500">
|
||||
Visited paths
|
||||
</div>
|
||||
{history.length === 0 ? (
|
||||
<p className="px-1 text-[12px] text-neutral-500">
|
||||
Nothing here yet. Look up a secret on the right to populate the tree.
|
||||
</p>
|
||||
) : (
|
||||
<TreeView
|
||||
tree={tree}
|
||||
selected={selected}
|
||||
onSelect={(ref) => setSelected(ref)}
|
||||
onForget={forget}
|
||||
/>
|
||||
)}
|
||||
</aside>
|
||||
|
||||
<section className="flex flex-1 flex-col overflow-y-auto p-6">
|
||||
<header className="mb-4">
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<header className="mb-5 flex items-end justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold text-neutral-50">Secrets</h1>
|
||||
<p className="text-[13px] text-neutral-400">
|
||||
Read, write, rotate, and delete one secret at a time. The canonical
|
||||
<code className="ml-1 text-neutral-300">/v1/kms/orgs/{org}/secrets</code> surface
|
||||
is path-addressed — no listing — so the tree on the left only shows what you have visited
|
||||
in this session.
|
||||
What your organization holds, by path and environment. A value stays sealed until you open it.
|
||||
</p>
|
||||
</header>
|
||||
</div>
|
||||
<Button variant="primary" onClick={() => setCreating((v) => !v)}>
|
||||
{creating ? 'Close' : 'New secret'}
|
||||
</Button>
|
||||
</header>
|
||||
|
||||
<LookupForm onLookup={(ref) => { record(ref); setSelected(ref) }} initial={selected} />
|
||||
{creating && (
|
||||
<Editor
|
||||
title="New secret"
|
||||
initial={{ path: filter.path, name: '', env: filter.env || '', value: '' }}
|
||||
onSaved={() => {
|
||||
setCreating(false)
|
||||
refresh()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selected && <SecretInspector ref_={selected} onForget={() => forget(selected)} />}
|
||||
</section>
|
||||
<form onSubmit={apply} className="mb-4 flex flex-wrap items-end gap-3">
|
||||
<div className="flex w-64 flex-col gap-1">
|
||||
<Label htmlFor="filter-path">Path</Label>
|
||||
<Input id="filter-path" placeholder="/ci" value={path} onChange={(e) => setPath(e.target.value)} />
|
||||
</div>
|
||||
<div className="flex w-40 flex-col gap-1">
|
||||
<Label htmlFor="filter-env">Environment</Label>
|
||||
<Input id="filter-env" placeholder="all" value={env} onChange={(e) => setEnv(e.target.value)} />
|
||||
</div>
|
||||
<Button type="submit">Filter</Button>
|
||||
</form>
|
||||
|
||||
{list.isPending && <p className="text-[13px] text-neutral-500">Loading secrets</p>}
|
||||
{list.isError && <Failure error={list.error} />}
|
||||
{list.data &&
|
||||
(list.data.secrets.length === 0 ? (
|
||||
<Card className="p-6 text-[13px] text-neutral-400">
|
||||
No secrets here yet. Create one with New secret.
|
||||
</Card>
|
||||
) : (
|
||||
<Card className="overflow-hidden">
|
||||
<table className="w-full text-left text-[13px]">
|
||||
<thead className="border-b border-neutral-900 text-[11px] uppercase tracking-wider text-neutral-500">
|
||||
<tr>
|
||||
<th className="px-4 py-2 font-medium">Name</th>
|
||||
<th className="px-4 py-2 font-medium">Path</th>
|
||||
<th className="px-4 py-2 font-medium">Environment</th>
|
||||
<th className="px-4 py-2 font-medium">Sealing</th>
|
||||
<th className="px-4 py-2" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{list.data.secrets.map((s) => (
|
||||
<Row key={`${s.env}|${s.path}|${s.name}`} secret={s} onChanged={refresh} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface LookupFormProps {
|
||||
onLookup: (ref: SecretRef) => void
|
||||
initial: SecretRef | null
|
||||
function Row({ secret, onChanged }: { secret: SecretMeta; onChanged: () => void }) {
|
||||
const [value, setValue] = useState<string | null>(null)
|
||||
const [replacing, setReplacing] = useState(false)
|
||||
|
||||
const open = useMutation({ mutationFn: () => openSecret(secret), onSuccess: (o) => setValue(o.value) })
|
||||
const remove = useMutation({ mutationFn: () => deleteSecret(secret), onSuccess: onChanged })
|
||||
|
||||
const error = open.error ?? remove.error
|
||||
|
||||
return (
|
||||
<>
|
||||
<tr className="border-b border-neutral-900 last:border-0">
|
||||
<td className="px-4 py-2 font-mono text-neutral-100">{secret.name}</td>
|
||||
<td className="px-4 py-2 font-mono text-neutral-400">{secret.path || '/'}</td>
|
||||
<td className="px-4 py-2">
|
||||
<Badge>{secret.env}</Badge>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-neutral-500">{secret.scheme}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex justify-end gap-1">
|
||||
{value === null ? (
|
||||
<Button variant="ghost" onClick={() => open.mutate()} disabled={open.isPending}>
|
||||
{open.isPending ? 'Opening' : 'Open'}
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="ghost" onClick={() => setValue(null)}>
|
||||
Hide
|
||||
</Button>
|
||||
)}
|
||||
<Button variant="ghost" onClick={() => setReplacing((v) => !v)}>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
variant="danger"
|
||||
disabled={remove.isPending}
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${secret.name} from ${secret.env}?`)) remove.mutate()
|
||||
}}
|
||||
>
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{(value !== null || replacing || error) && (
|
||||
<tr className="border-b border-neutral-900">
|
||||
<td colSpan={5} className="space-y-3 bg-black px-4 py-3">
|
||||
{error && <Failure error={error} />}
|
||||
{value !== null && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<CodeBlock>{value}</CodeBlock>
|
||||
<div>
|
||||
<Button variant="secondary" onClick={() => void navigator.clipboard.writeText(value)}>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{replacing && (
|
||||
<Editor
|
||||
title={`Replace ${secret.name}`}
|
||||
initial={{ path: secret.path, name: secret.name, env: secret.env, value: '' }}
|
||||
fixed
|
||||
onSaved={() => {
|
||||
setReplacing(false)
|
||||
setValue(null)
|
||||
onChanged()
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function LookupForm({ onLookup, initial }: LookupFormProps) {
|
||||
const [path, setPath] = useState(initial?.path || '')
|
||||
const [name, setName] = useState(initial?.name || '')
|
||||
const [env, setEnv] = useState(initial?.env || 'default')
|
||||
|
||||
useEffect(() => {
|
||||
if (initial) {
|
||||
setPath(initial.path)
|
||||
setName(initial.name)
|
||||
setEnv(initial.env)
|
||||
}
|
||||
}, [initial])
|
||||
function Editor({
|
||||
title,
|
||||
initial,
|
||||
fixed = false,
|
||||
onSaved,
|
||||
}: {
|
||||
title: string
|
||||
initial: Draft
|
||||
fixed?: boolean
|
||||
onSaved: () => void
|
||||
}) {
|
||||
const [draft, setDraft] = useState<Draft>(initial)
|
||||
const save = useMutation({ mutationFn: () => putSecret(draft), onSuccess: onSaved })
|
||||
const set = (key: keyof Draft) => (e: { target: { value: string } }) => setDraft({ ...draft, [key]: e.target.value })
|
||||
|
||||
function submit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!name.trim()) return
|
||||
onLookup({
|
||||
path: normalizePath(path),
|
||||
name: name.trim(),
|
||||
env: env.trim() || 'default',
|
||||
})
|
||||
save.mutate()
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="mb-6 p-4">
|
||||
<form onSubmit={submit} className="grid grid-cols-12 gap-3">
|
||||
<div className="col-span-5 flex flex-col gap-1">
|
||||
<Label htmlFor="path">Path</Label>
|
||||
<Input
|
||||
id="path"
|
||||
placeholder="commerce/prod"
|
||||
value={path}
|
||||
onChange={(e) => setPath(e.target.value)}
|
||||
/>
|
||||
<Card className="mb-4 p-4">
|
||||
<form onSubmit={submit} className="flex flex-col gap-3">
|
||||
<h2 className="text-sm font-semibold text-neutral-100">{title}</h2>
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={`${title}-path`}>Path</Label>
|
||||
<Input id={`${title}-path`} placeholder="/ci" value={draft.path} onChange={set('path')} disabled={fixed} />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={`${title}-name`}>Name</Label>
|
||||
<Input id={`${title}-name`} placeholder="NPM_TOKEN" value={draft.name} onChange={set('name')} disabled={fixed} required />
|
||||
</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={`${title}-env`}>Environment</Label>
|
||||
<Input id={`${title}-env`} placeholder="prod" value={draft.env} onChange={set('env')} disabled={fixed} required />
|
||||
</div>
|
||||
</div>
|
||||
<div className="col-span-3 flex flex-col gap-1">
|
||||
<Label htmlFor="name">Name</Label>
|
||||
<Input
|
||||
id="name"
|
||||
placeholder="STRIPE_LIVE_KEY"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
required
|
||||
/>
|
||||
<div className="flex flex-col gap-1">
|
||||
<Label htmlFor={`${title}-value`}>Value</Label>
|
||||
<TextArea id={`${title}-value`} rows={3} value={draft.value} onChange={set('value')} required autoComplete="off" spellCheck={false} />
|
||||
</div>
|
||||
<div className="col-span-2 flex flex-col gap-1">
|
||||
<Label htmlFor="env">Env</Label>
|
||||
<Input id="env" placeholder="default" value={env} onChange={(e) => setEnv(e.target.value)} />
|
||||
</div>
|
||||
<div className="col-span-2 flex items-end">
|
||||
<Button type="submit" variant="primary" className="w-full">
|
||||
Open
|
||||
{save.error && <Failure error={save.error} />}
|
||||
<div>
|
||||
<Button type="submit" variant="primary" disabled={save.isPending || !draft.value}>
|
||||
{save.isPending ? 'Saving' : 'Save'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
interface SecretInspectorProps {
|
||||
ref_: SecretRef
|
||||
onForget: () => void
|
||||
}
|
||||
|
||||
function SecretInspector({ ref_, onForget }: SecretInspectorProps) {
|
||||
const qc = useQueryClient()
|
||||
const org = getOrg()
|
||||
const queryKey = ['secret', org, ref_.path, ref_.name, ref_.env]
|
||||
const { data, error, isLoading, isFetching } = useQuery({
|
||||
queryKey,
|
||||
queryFn: () => getSecret(org, ref_.path, ref_.name, ref_.env),
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const [value, setValue] = useState('')
|
||||
const [reveal, setReveal] = useState(false)
|
||||
const [editing, setEditing] = useState(false)
|
||||
const [mutError, setMutError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setValue(data?.secret.value || '')
|
||||
setEditing(false)
|
||||
setMutError(null)
|
||||
setReveal(false)
|
||||
}, [data, ref_.env, ref_.path, ref_.name])
|
||||
|
||||
const patchMut = useMutation({
|
||||
mutationFn: () =>
|
||||
patchSecret(org, ref_.path, ref_.name, {
|
||||
value,
|
||||
version: data!.version,
|
||||
env: ref_.env,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
setEditing(false)
|
||||
qc.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: (e) => setMutError((e as Error).message),
|
||||
})
|
||||
|
||||
const putMut = useMutation({
|
||||
mutationFn: () =>
|
||||
putSecret(org, {
|
||||
path: ref_.path,
|
||||
name: ref_.name,
|
||||
env: ref_.env,
|
||||
value,
|
||||
}),
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey })
|
||||
},
|
||||
onError: (e) => setMutError((e as Error).message),
|
||||
})
|
||||
|
||||
const delMut = useMutation({
|
||||
mutationFn: () => deleteSecret(org, ref_.path, ref_.name, ref_.env),
|
||||
onSuccess: () => {
|
||||
qc.removeQueries({ queryKey })
|
||||
onForget()
|
||||
},
|
||||
onError: (e) => setMutError((e as Error).message),
|
||||
})
|
||||
|
||||
const notFound = error instanceof ApiError && error.status === 404
|
||||
const otherErr = error && !notFound ? (error as Error).message : null
|
||||
|
||||
return (
|
||||
<Card className="flex flex-col gap-3 p-4">
|
||||
<header className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-[12px] text-neutral-500">
|
||||
<code className="text-neutral-300">{ref_.path || '/'}</code> ·{' '}
|
||||
<Badge variant="brand">{ref_.env}</Badge>
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-neutral-50">{ref_.name}</h2>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{data && <Badge>v{data.version}</Badge>}
|
||||
{isFetching && <span className="text-[11px] text-neutral-500">refreshing…</span>}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{isLoading && <p className="text-[12px] text-neutral-500">Loading…</p>}
|
||||
|
||||
{otherErr && (
|
||||
<p className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-[12px] text-red-200">
|
||||
{otherErr}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{notFound && (
|
||||
<div className="flex flex-col gap-3 rounded-md border border-amber-900/60 bg-amber-950/40 px-3 py-3 text-[12px] text-amber-200">
|
||||
<p>Secret not found. Type a value and write it to create v1.</p>
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
rows={3}
|
||||
className="w-full rounded-md border border-neutral-800 bg-neutral-950 px-2 py-1 font-mono text-[12px] text-neutral-100"
|
||||
placeholder="secret value"
|
||||
/>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setMutError(null)
|
||||
putMut.mutate()
|
||||
}}
|
||||
disabled={!value.trim() || putMut.isPending}
|
||||
>
|
||||
{putMut.isPending ? 'Creating…' : 'Create v1'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data && !notFound && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<Label>Value</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button variant="ghost" onClick={() => setReveal((v) => !v)}>
|
||||
{reveal ? 'Hide' : 'Reveal'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={async () => {
|
||||
await navigator.clipboard.writeText(data.secret.value)
|
||||
}}
|
||||
>
|
||||
Copy
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{editing ? (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
rows={4}
|
||||
className="w-full rounded-md border border-neutral-800 bg-neutral-950 px-2 py-1 font-mono text-[12px] text-neutral-100"
|
||||
/>
|
||||
) : reveal ? (
|
||||
<CodeBlock>{data.secret.value}</CodeBlock>
|
||||
) : (
|
||||
<CodeBlock>{'•'.repeat(Math.min(data.secret.value.length, 48))}</CodeBlock>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{mutError && (
|
||||
<p className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-[12px] text-red-200">
|
||||
{mutError}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex gap-2">
|
||||
{!editing && (
|
||||
<Button variant="primary" onClick={() => setEditing(true)}>
|
||||
Rotate
|
||||
</Button>
|
||||
)}
|
||||
{editing && (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => {
|
||||
setMutError(null)
|
||||
patchMut.mutate()
|
||||
}}
|
||||
disabled={patchMut.isPending || !value.trim()}
|
||||
>
|
||||
{patchMut.isPending ? 'Saving…' : `Save v${data.version + 1}`}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
setEditing(false)
|
||||
setValue(data.secret.value)
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="danger"
|
||||
onClick={() => {
|
||||
if (window.confirm(`Delete ${ref_.name}@${ref_.env}? Versioning resets to 0.`)) {
|
||||
setMutError(null)
|
||||
delMut.mutate()
|
||||
}
|
||||
}}
|
||||
disabled={delMut.isPending}
|
||||
>
|
||||
{delMut.isPending ? 'Deleting…' : 'Delete'}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<p className="text-[11px] text-neutral-500">
|
||||
PATCH bumps version with strict CAS via the <code>If-Match</code> header. POST is upsert.
|
||||
DELETE clears the version record so a re-create starts at v1.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Tree view --------------------------------------------------------
|
||||
|
||||
interface TreeNode {
|
||||
segment: string
|
||||
full: string
|
||||
children: TreeNode[]
|
||||
refs: SecretRef[]
|
||||
}
|
||||
|
||||
function buildTree(refs: SecretRef[]): TreeNode {
|
||||
const root: TreeNode = { segment: '', full: '', children: [], refs: [] }
|
||||
for (const ref of refs) {
|
||||
const parts = ref.path.split('/').filter(Boolean)
|
||||
let cur = root
|
||||
let acc = ''
|
||||
for (const p of parts) {
|
||||
acc = acc ? `${acc}/${p}` : p
|
||||
let next = cur.children.find((c) => c.segment === p)
|
||||
if (!next) {
|
||||
next = { segment: p, full: acc, children: [], refs: [] }
|
||||
cur.children.push(next)
|
||||
}
|
||||
cur = next
|
||||
}
|
||||
cur.refs.push(ref)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
function TreeView({
|
||||
tree,
|
||||
selected,
|
||||
onSelect,
|
||||
onForget,
|
||||
}: {
|
||||
tree: TreeNode
|
||||
selected: SecretRef | null
|
||||
onSelect: (ref: SecretRef) => void
|
||||
onForget: (ref: SecretRef) => void
|
||||
}) {
|
||||
return (
|
||||
<ul className="flex flex-col gap-px">
|
||||
{tree.refs.map((r) => (
|
||||
<RefLine
|
||||
key={refKey(r)}
|
||||
ref_={r}
|
||||
active={selected && refKey(selected) === refKey(r)}
|
||||
onSelect={onSelect}
|
||||
onForget={onForget}
|
||||
/>
|
||||
))}
|
||||
{tree.children.map((c) => (
|
||||
<TreeBranch
|
||||
key={c.full}
|
||||
node={c}
|
||||
depth={0}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onForget={onForget}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
}
|
||||
|
||||
function TreeBranch({
|
||||
node,
|
||||
depth,
|
||||
selected,
|
||||
onSelect,
|
||||
onForget,
|
||||
}: {
|
||||
node: TreeNode
|
||||
depth: number
|
||||
selected: SecretRef | null
|
||||
onSelect: (ref: SecretRef) => void
|
||||
onForget: (ref: SecretRef) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(true)
|
||||
return (
|
||||
<li>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="flex w-full items-center gap-1 rounded-sm px-1 py-0.5 text-left text-[12px] text-neutral-300 hover:bg-neutral-900"
|
||||
style={{ paddingLeft: depth * 12 + 4 }}
|
||||
>
|
||||
<span className="font-mono text-[10px] text-neutral-500">{open ? '▾' : '▸'}</span>
|
||||
<span>{node.segment}/</span>
|
||||
</button>
|
||||
{open && (
|
||||
<ul className="flex flex-col gap-px">
|
||||
{node.refs.map((r) => (
|
||||
<RefLine
|
||||
key={refKey(r)}
|
||||
ref_={r}
|
||||
depth={depth + 1}
|
||||
active={selected && refKey(selected) === refKey(r)}
|
||||
onSelect={onSelect}
|
||||
onForget={onForget}
|
||||
/>
|
||||
))}
|
||||
{node.children.map((c) => (
|
||||
<TreeBranch
|
||||
key={c.full}
|
||||
node={c}
|
||||
depth={depth + 1}
|
||||
selected={selected}
|
||||
onSelect={onSelect}
|
||||
onForget={onForget}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function RefLine({
|
||||
ref_,
|
||||
depth = 0,
|
||||
active,
|
||||
onSelect,
|
||||
onForget,
|
||||
}: {
|
||||
ref_: SecretRef
|
||||
depth?: number
|
||||
active: boolean | null
|
||||
onSelect: (r: SecretRef) => void
|
||||
onForget: (r: SecretRef) => void
|
||||
}) {
|
||||
return (
|
||||
<li className="group flex items-center gap-1" style={{ paddingLeft: depth * 12 + 16 }}>
|
||||
<button
|
||||
onClick={() => onSelect(ref_)}
|
||||
className={
|
||||
'flex-1 truncate rounded-sm px-1.5 py-0.5 text-left text-[12px] ' +
|
||||
(active
|
||||
? 'bg-neutral-800/80 text-neutral-50'
|
||||
: 'text-neutral-300 hover:bg-neutral-900 hover:text-neutral-100')
|
||||
}
|
||||
title={`${ref_.path}/${ref_.name} (${ref_.env})`}
|
||||
>
|
||||
{ref_.name}
|
||||
<span className="ml-1 text-[10px] text-neutral-500">{ref_.env}</span>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onForget(ref_)}
|
||||
className="opacity-0 transition-opacity group-hover:opacity-100"
|
||||
title="Forget"
|
||||
>
|
||||
<span className="text-[10px] text-neutral-500 hover:text-neutral-200">×</span>
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
function normalizePath(p: string): string {
|
||||
return p
|
||||
.split('/')
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean)
|
||||
.join('/')
|
||||
}
|
||||
|
||||
+37
-101
@@ -1,127 +1,63 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { fetchHealth, fetchStatus, getToken, decodeToken, ApiError } from '@/lib/api'
|
||||
import { Badge, Card, CodeBlock } from '@/components/Button'
|
||||
import { fetchHealth } from '@/lib/api'
|
||||
import type { Config } from '@/lib/kms'
|
||||
import { who } from '@/lib/session'
|
||||
import { Badge, Card } from '@/components/ui'
|
||||
import { Failure } from '@/components/Failure'
|
||||
|
||||
// Status — combines /v1/kms/health (always served) and /v1/kms/status
|
||||
// (admin-only, surfaces MPC connectivity). Also decodes the active JWT
|
||||
// locally so operators can confirm the issuer/audience/owner they're
|
||||
// Status: readiness from GET /v1/kms/health, and the configuration this console
|
||||
// signed in with.
|
||||
|
||||
export function StatusPage() {
|
||||
const health = useQuery({
|
||||
queryKey: ['health'],
|
||||
queryFn: fetchHealth,
|
||||
refetchInterval: 5000,
|
||||
retry: false,
|
||||
})
|
||||
const status = useQuery({
|
||||
queryKey: ['status'],
|
||||
queryFn: fetchStatus,
|
||||
refetchInterval: 10000,
|
||||
retry: false,
|
||||
})
|
||||
|
||||
const claims = decodeToken(getToken())
|
||||
export function StatusPage({ config }: { config: Config }) {
|
||||
const health = useQuery({ queryKey: ['health'], queryFn: fetchHealth, refetchInterval: 10_000 })
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<header className="mb-4">
|
||||
<div className="mx-auto max-w-5xl p-6">
|
||||
<header className="mb-5">
|
||||
<h1 className="text-lg font-semibold text-neutral-50">Status</h1>
|
||||
<p className="text-[13px] text-neutral-400">
|
||||
Liveness from <code>/v1/kms/health</code>, MPC connectivity from <code>/v1/kms/status</code>.
|
||||
</p>
|
||||
<p className="text-[13px] text-neutral-400">Whether the store can serve secrets right now.</p>
|
||||
</header>
|
||||
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
|
||||
<Card className="p-5">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-neutral-200">KMS</h2>
|
||||
<Badge variant={health.data?.status === 'ok' ? 'success' : 'danger'}>
|
||||
{health.isLoading ? '…' : health.data?.status || 'unknown'}
|
||||
</Badge>
|
||||
<header className="mb-3 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-neutral-200">Store</h2>
|
||||
{health.data && (
|
||||
<Badge variant={health.data.ready ? 'success' : 'danger'}>{health.data.ready ? 'ready' : 'not ready'}</Badge>
|
||||
)}
|
||||
</header>
|
||||
<dl className="grid grid-cols-3 gap-1 text-[12px]">
|
||||
<dt className="text-neutral-500">service</dt>
|
||||
<dd className="col-span-2 text-neutral-200">{health.data?.service || '—'}</dd>
|
||||
<dt className="text-neutral-500">version</dt>
|
||||
<dd className="col-span-2 font-mono text-neutral-200">{health.data?.version || '—'}</dd>
|
||||
</dl>
|
||||
</Card>
|
||||
|
||||
<Card className="p-5">
|
||||
<header className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-sm font-semibold text-neutral-200">MPC</h2>
|
||||
<Badge variant={mpcVariant(status.data, status.error)}>
|
||||
{mpcLabel(status.data, status.error, status.isLoading)}
|
||||
</Badge>
|
||||
</header>
|
||||
{status.error && status.error instanceof ApiError && status.error.status === 403 ? (
|
||||
<p className="text-[12px] text-neutral-500">
|
||||
Admin role required to view MPC status.
|
||||
</p>
|
||||
) : (
|
||||
<pre className="overflow-auto rounded-md border border-neutral-900 bg-neutral-925/60 p-3 font-mono text-[11px] text-neutral-200">
|
||||
{status.isLoading
|
||||
? 'loading…'
|
||||
: JSON.stringify(status.data?.mpc ?? status.error, null, 2)}
|
||||
</pre>
|
||||
{health.isPending && <p className="text-[13px] text-neutral-500">Checking</p>}
|
||||
{health.isError && <Failure error={health.error} />}
|
||||
{health.data && (
|
||||
<dl className="grid grid-cols-3 gap-y-1 text-[13px]">
|
||||
<Field label="Service" value={health.data.service} />
|
||||
<Field label="Status" value={health.data.status} />
|
||||
{health.data.signing !== undefined && (
|
||||
<Field label="Signing" value={health.data.signing ? 'configured' : 'not configured'} />
|
||||
)}
|
||||
{health.data.error && <Field label="Reason" value={health.data.error} />}
|
||||
</dl>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{claims && (
|
||||
<Card className="mt-6 p-5">
|
||||
<h2 className="mb-2 text-sm font-semibold text-neutral-200">Active token</h2>
|
||||
<p className="text-[12px] text-neutral-500">
|
||||
Decoded locally — signature is verified by kmsd on every request, not by the browser.
|
||||
</p>
|
||||
<dl className="mt-3 grid grid-cols-3 gap-2 text-[12px]">
|
||||
<Field label="iss" value={claims.iss} />
|
||||
<Field label="aud" value={Array.isArray(claims.aud) ? claims.aud.join(', ') : claims.aud} />
|
||||
<Field label="sub" value={claims.sub} />
|
||||
<Field label="owner" value={claims.owner} />
|
||||
<Field label="roles" value={(claims.roles || []).join(', ') || '—'} />
|
||||
<Field label="expires" value={claims.exp ? new Date(claims.exp * 1000).toISOString() : '—'} />
|
||||
<Card className="p-5">
|
||||
<h2 className="mb-3 text-sm font-semibold text-neutral-200">Session</h2>
|
||||
<dl className="grid grid-cols-3 gap-y-1 text-[13px]">
|
||||
<Field label="Signed in as" value={who()} />
|
||||
<Field label="Issuer" value={config.issuer} />
|
||||
<Field label="API" value={config.apiBase} />
|
||||
</dl>
|
||||
<CodeBlock>{JSON.stringify(claims, null, 2)}</CodeBlock>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, value }: { label: string; value?: string }) {
|
||||
function Field({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<>
|
||||
<dt className="text-neutral-500">{label}</dt>
|
||||
<dd className="col-span-2 font-mono text-neutral-200">{value || '—'}</dd>
|
||||
<dd className="col-span-2 break-all font-mono text-neutral-200">{value || 'none'}</dd>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function mpcLabel(
|
||||
d: { mpc: unknown; details?: string } | undefined,
|
||||
err: unknown,
|
||||
loading: boolean,
|
||||
): string {
|
||||
if (loading) return 'loading'
|
||||
if (err instanceof ApiError && err.status === 403) return 'forbidden'
|
||||
if (err) return 'error'
|
||||
if (typeof d?.mpc === 'string') return d.mpc
|
||||
if (d?.mpc && typeof d.mpc === 'object' && 'Ready' in d.mpc) {
|
||||
return (d.mpc as { Ready: boolean }).Ready ? 'ready' : 'unready'
|
||||
}
|
||||
return 'unknown'
|
||||
}
|
||||
|
||||
function mpcVariant(
|
||||
d: { mpc: unknown } | undefined,
|
||||
err: unknown,
|
||||
): 'success' | 'warn' | 'danger' | 'neutral' {
|
||||
if (err) return 'danger'
|
||||
if (typeof d?.mpc === 'string' && d.mpc !== 'unreachable') return 'success'
|
||||
if (d?.mpc && typeof d.mpc === 'object' && 'Ready' in d.mpc) {
|
||||
return (d.mpc as { Ready: boolean }).Ready ? 'success' : 'warn'
|
||||
}
|
||||
return 'neutral'
|
||||
}
|
||||
|
||||
+14
-3
@@ -2,7 +2,11 @@
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"lib": [
|
||||
"ES2022",
|
||||
"DOM",
|
||||
"DOM.Iterable"
|
||||
],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "Bundler",
|
||||
@@ -20,7 +24,14 @@
|
||||
"./src/*"
|
||||
]
|
||||
},
|
||||
"types": ["vite/client"]
|
||||
"types": [
|
||||
"vite/client"
|
||||
]
|
||||
},
|
||||
"include": ["src", "src/global.d.ts"]
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
|
||||
+5
-13
@@ -3,11 +3,9 @@ import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/postcss'
|
||||
import path from 'node:path'
|
||||
|
||||
// Hanzo KMS admin SPA.
|
||||
//
|
||||
// Single-page React app served from KMS_FRONTEND_DIR by kmsd's mux.
|
||||
// All API calls go to /v1/kms/* — same origin as the SPA in production.
|
||||
// In dev, Vite proxies /v1/kms to the local kmsd listener on :8443.
|
||||
// The console is a static bundle. In production the same host routes /v1 to
|
||||
// cloud, which serves the KMS API; in development Vite proxies /v1 to a local
|
||||
// cloud on :8000.
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
@@ -29,15 +27,9 @@ export default defineConfig({
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/v1/kms': {
|
||||
target: 'http://127.0.0.1:8443',
|
||||
'/v1': {
|
||||
target: 'http://127.0.0.1:8000',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
'/healthz': {
|
||||
target: 'http://127.0.0.1:8443',
|
||||
changeOrigin: true,
|
||||
secure: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
// Tests for the SPA catch-all handler (registerFrontend). The critical
|
||||
// invariant: /api/ is NOT a KMS surface, so the catch-all must 404 it
|
||||
// rather than answer with index.html (200). Without that guard, a GET to
|
||||
// /api/v1/... falls through to the React-Router fallback and returns 200,
|
||||
// masquerading as a live legacy /api/ backend — the exact false positive
|
||||
// that made kms.hanzo.ai look like it still served /api/v1/*. The ONE
|
||||
// canonical prefix is /v1/kms; there is no /api/, and the wire must say so.
|
||||
package kms
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// newSPATestServer builds a mux with the frontend catch-all active against
|
||||
// a temp dir that holds a real index.html + one concrete asset, so both the
|
||||
// SPA-fallback and static-file branches are exercised.
|
||||
func newSPATestServer(t *testing.T) (*httptest.Server, func()) {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(dir, "index.html"), []byte("<!doctype html><title>kms</title>"), 0o600); err != nil {
|
||||
t.Fatalf("write index.html: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "app.js"), []byte("console.log('kms')"), 0o600); err != nil {
|
||||
t.Fatalf("write app.js: %v", err)
|
||||
}
|
||||
t.Setenv("KMS_FRONTEND_DIR", dir)
|
||||
|
||||
mux := http.NewServeMux()
|
||||
registerHealth(mux)
|
||||
registerFrontend(mux)
|
||||
|
||||
srv := httptest.NewServer(mux)
|
||||
return srv, srv.Close
|
||||
}
|
||||
|
||||
func TestSPA_NoApiSurface(t *testing.T) {
|
||||
srv, cleanup := newSPATestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// /api/ in any shape must 404 — never the SPA 200. This is the core
|
||||
// "there is no /api/" guarantee at the wire.
|
||||
apiPaths := []string{
|
||||
"/api",
|
||||
"/api/",
|
||||
"/api/v1/auth/universal-auth/login",
|
||||
"/api/v1/secrets",
|
||||
"/api/status/health",
|
||||
"/api/anything/at/all",
|
||||
}
|
||||
for _, p := range apiPaths {
|
||||
resp, err := http.Get(srv.URL + p)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", p, err)
|
||||
}
|
||||
body := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
if body != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404 (no /api/ surface exists)", p, body)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPA_ApiRouteBailoutsStay404(t *testing.T) {
|
||||
srv, cleanup := newSPATestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Defense-in-depth bail-outs: these never reach the SPA fallback.
|
||||
for _, p := range []string{"/v1/kms/anything", "/health"} {
|
||||
resp, err := http.Get(srv.URL + p)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", p, err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
if code != http.StatusNotFound {
|
||||
t.Errorf("GET %s = %d, want 404", p, code)
|
||||
}
|
||||
}
|
||||
// /healthz has a real handler (registerHealth) — must be 200, proving
|
||||
// the more-specific route wins over the catch-all.
|
||||
resp, err := http.Get(srv.URL + "/healthz")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /healthz: %v", err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("GET /healthz = %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSPA_ClientRoutesServeIndex(t *testing.T) {
|
||||
srv, cleanup := newSPATestServer(t)
|
||||
defer cleanup()
|
||||
|
||||
// Real client-side routes and the root still get index.html (200) so
|
||||
// React Router works. This proves the /api/ 404 guard did not break the
|
||||
// legitimate SPA fallback.
|
||||
for _, p := range []string{"/", "/login", "/dashboard", "/keys"} {
|
||||
resp, err := http.Get(srv.URL + p)
|
||||
if err != nil {
|
||||
t.Fatalf("GET %s: %v", p, err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("GET %s = %d, want 200 (SPA fallback)", p, code)
|
||||
}
|
||||
}
|
||||
|
||||
// Concrete on-disk asset is served directly.
|
||||
resp, err := http.Get(srv.URL + "/app.js")
|
||||
if err != nil {
|
||||
t.Fatalf("GET /app.js: %v", err)
|
||||
}
|
||||
code := resp.StatusCode
|
||||
resp.Body.Close()
|
||||
if code != http.StatusOK {
|
||||
t.Errorf("GET /app.js = %d, want 200 (static asset)", code)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user