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:
zooqueen
2026-09-15 11:33:56 -07:00
co-authored by Hanzo Dev
parent 1d211df160
commit 81e27efff6
29 changed files with 891 additions and 2140 deletions
+3 -4
View File
@@ -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
View File
@@ -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
+9 -94
View File
@@ -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
View File
@@ -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
View File
@@ -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>
+5 -4
View File
@@ -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",
+72 -6
View File
@@ -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
View File
@@ -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"
/>
)
}
-153
View File
@@ -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>
)
}
-105
View File
@@ -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>
)
}
-166
View File
@@ -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)]"
/>
)
}
+18
View File
@@ -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>
)
}
+70
View File
@@ -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>
)
}
+75
View File
@@ -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>
)
}
-17
View File
@@ -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
View File
@@ -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
View File
@@ -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' })
}
+52
View File
@@ -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'), '')
})
})
+119
View File
@@ -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 ''
}
+38
View File
@@ -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)
}
-87
View File
@@ -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>
)
}
-41
View File
@@ -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>
)
}
-127
View File
@@ -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/&#123;id&#125;/sign</code> and{' '}
<code>/v1/kms/keys/&#123;id&#125;/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>
)
}
-104
View File
@@ -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
View File
@@ -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/&#123;org&#125;/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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
},
},
},
-125
View File
@@ -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)
}
}