lightscale-admin/frontend/src/App.tsx
centra 98eb7057a5
Some checks failed
build-local-image / build (push) Has been cancelled
Implement user-bound join flows and add admin image build pipeline
2026-02-14 15:46:25 +09:00

2101 lines
64 KiB
TypeScript

import { useEffect, useMemo, useState, type FormEvent } from 'react'
import './App.css'
import {
addMembership,
approveNode,
createControlPlane,
createNetwork,
createToken,
createUser,
deleteControlPlane,
deleteMembership,
deleteNetwork,
getAcl,
getKeyPolicy,
getMe,
getUser,
listAdminAudit,
listControlPlaneAudit,
listControlPlanes,
listNetworks,
listNodes,
listProviders,
listRoles,
listUsers,
login,
logout,
nodeKeys,
revokeNode,
revokeToken,
rotateNodeKeys,
setUserPassword,
updateAcl,
updateControlPlane,
updateKeyPolicy,
updateUser,
verifyControlPlane,
type AdminAuditEntry,
type AdminNodesResponse,
type AuditLogResponse,
type ControlPlaneSummary,
type CreateTokenResponse,
type EnrollmentToken,
type KeyHistoryResponse,
type KeyPolicyResponse,
type NetworkSummary,
type NodeInfo,
type AuthSession,
type AuthUserSummary,
type ProviderSummary,
type RoleSummary,
type UserDetail,
type UserSummary,
} from './api'
type SectionId =
| 'overview'
| 'control-planes'
| 'networks'
| 'nodes'
| 'tokens'
| 'acl'
| 'key-policy'
| 'users'
| 'audit'
type BannerTone = 'info' | 'warning' | 'error' | 'success'
type BannerMessage = {
tone: BannerTone
text: string
}
const sections: { id: SectionId; label: string; hint: string }[] = [
{ id: 'overview', label: 'Overview', hint: 'Pulse & posture' },
{ id: 'control-planes', label: 'Control Planes', hint: 'Origins & routes' },
{ id: 'networks', label: 'Networks', hint: 'Provision & catalog' },
{ id: 'nodes', label: 'Nodes', hint: 'Approve & rotate' },
{ id: 'tokens', label: 'Tokens', hint: 'Invite securely' },
{ id: 'acl', label: 'ACL', hint: 'Policy canvas' },
{ id: 'key-policy', label: 'Key Policy', hint: 'Rotation cadence' },
{ id: 'users', label: 'Users', hint: 'Access graph' },
{ id: 'audit', label: 'Audit', hint: 'Trace every move' },
]
const DEFAULT_SCOPE = { scope_type: 'global', scope_id: 'global' }
function App() {
const [authReady, setAuthReady] = useState(false)
const [session, setSession] = useState<AuthSession | null>(null)
useEffect(() => {
getMe()
.then((me) => setSession(me))
.catch(() => setSession(null))
.finally(() => setAuthReady(true))
}, [])
if (!authReady) {
return (
<div className="screen center">
<div className="loader">Loading console...</div>
</div>
)
}
if (!session) {
return <LoginScreen onAuthed={setSession} />
}
if (!session.console_access) {
return (
<div className="screen center">
<div className="card" style={{ maxWidth: 560 }}>
<h3>Console access is not granted</h3>
<p className="muted">
This account is authenticated, but it does not have the
<code>console:access</code> permission.
</p>
<div className="button-row">
<button
className="ghost"
onClick={async () => {
await logout()
setSession(null)
}}
>
Sign out
</button>
</div>
</div>
</div>
)
}
return (
<AdminShell
user={session.user}
permissions={session.permissions}
onLogout={() => setSession(null)}
onSessionUpdated={setSession}
/>
)
}
function LoginScreen({ onAuthed }: { onAuthed: (session: AuthSession) => void }) {
const [email, setEmail] = useState('')
const [password, setPassword] = useState('')
const [providers, setProviders] = useState<ProviderSummary[]>([])
const [error, setError] = useState<string | null>(null)
const [loading, setLoading] = useState(false)
useEffect(() => {
listProviders()
.then(setProviders)
.catch(() => setProviders([]))
}, [])
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
setLoading(true)
setError(null)
try {
const response = await login(email, password)
onAuthed(response)
} catch (err) {
setError((err as Error).message)
} finally {
setLoading(false)
}
}
return (
<div className="screen">
<div className="login-grid">
<div className="login-panel">
<div className="brand">
<span className="brand-mark">LS</span>
<div>
<p className="brand-title">Lightscale Admin</p>
<p className="brand-sub">Orchestrate control planes with intent.</p>
</div>
</div>
<form onSubmit={handleSubmit} className="stack">
<label>
<span>Email</span>
<input
type="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
<span>Password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
{error && <div className="notice error">{error}</div>}
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{providers.length > 0 && (
<div className="oidc-block">
<p className="section-title">Single sign-on</p>
<div className="pill-row">
{providers.map((provider) => (
<a
key={provider.id}
className="pill"
href={`/admin/api/auth/oidc/${provider.id}/login?next=/`}
>
{provider.name}
</a>
))}
</div>
</div>
)}
</div>
<div className="login-hero">
<div className="hero-card">
<p className="hero-title">Zero single points of failure.</p>
<p className="hero-copy">
Build a multi-region control plane ledger backed by CockroachDB and manage it
in one place.
</p>
<div className="hero-points">
<div>
<h4>Layered auth</h4>
<p>Local accounts, OIDC, and scoped roles with audit trails.</p>
</div>
<div>
<h4>Operational focus</h4>
<p>Approve nodes, rotate keys, and author network policy rapidly.</p>
</div>
<div>
<h4>Designed to scale</h4>
<p>Aggregate multiple control planes without a monolith.</p>
</div>
</div>
</div>
</div>
</div>
</div>
)
}
function AdminShell({
user,
permissions,
onLogout,
onSessionUpdated,
}: {
user: AuthUserSummary
permissions: string[]
onLogout: () => void
onSessionUpdated: (session: AuthSession) => void
}) {
const [active, setActive] = useState<SectionId>('overview')
const [banner, setBanner] = useState<BannerMessage | null>(null)
const [controlPlanes, setControlPlanes] = useState<ControlPlaneSummary[]>([])
const [networks, setNetworks] = useState<NetworkSummary[]>([])
const [roles, setRoles] = useState<RoleSummary[]>([])
const [users, setUsers] = useState<UserSummary[]>([])
const [selectedControlPlaneId, setSelectedControlPlaneId] = useState<string>('')
const [selectedNetworkId, setSelectedNetworkId] = useState<string>('')
const [selectedUser, setSelectedUser] = useState<UserDetail | null>(null)
const [nodesResponse, setNodesResponse] = useState<AdminNodesResponse | null>(null)
const [keyHistory, setKeyHistory] = useState<KeyHistoryResponse | null>(null)
const [aclText, setAclText] = useState('')
const [keyPolicy, setKeyPolicy] = useState<KeyPolicyResponse | null>(null)
const [tokenResult, setTokenResult] = useState<CreateTokenResponse | null>(null)
const [revokedToken, setRevokedToken] = useState<EnrollmentToken | null>(null)
const [bootstrapToken, setBootstrapToken] = useState<EnrollmentToken | null>(null)
const [adminAudit, setAdminAudit] = useState<AdminAuditEntry[]>([])
const [controlPlaneAudit, setControlPlaneAudit] = useState<AuditLogResponse | null>(null)
const selectedNetwork = useMemo(
() => networks.find((network) => network.id === selectedNetworkId) || null,
[networks, selectedNetworkId],
)
const selectedNodeCount = nodesResponse?.nodes.length ?? 0
const permissionSet = useMemo(() => new Set(permissions), [permissions])
const hasPermission = (permission: string) =>
user.super_admin || permissionSet.has(permission)
const canControlPlanesRead = hasPermission('control_planes:read')
const canControlPlanesWrite = hasPermission('control_planes:write')
const canNetworksRead = hasPermission('networks:read')
const canNetworksWrite = hasPermission('networks:write')
const canNodesRead = hasPermission('nodes:read')
const canNodesWrite = hasPermission('nodes:write')
const canTokensWrite = hasPermission('tokens:write')
const canAclRead = hasPermission('acl:read')
const canAclWrite = hasPermission('acl:write')
const canKeyPolicyRead = hasPermission('key_policy:read')
const canKeyPolicyWrite = hasPermission('key_policy:write')
const canUsersRead = hasPermission('users:read')
const canUsersWrite = hasPermission('users:write')
const canRolesRead = hasPermission('roles:read')
const canAuditRead = hasPermission('audit:read')
const visibleSections = useMemo(
() =>
sections.filter((section) => {
switch (section.id) {
case 'overview':
return true
case 'control-planes':
return canControlPlanesRead
case 'networks':
return canNetworksRead
case 'nodes':
return canNodesRead
case 'tokens':
return canTokensWrite
case 'acl':
return canAclRead
case 'key-policy':
return canKeyPolicyRead
case 'users':
return canUsersRead
case 'audit':
return canAuditRead
}
}),
[
canAclRead,
canAuditRead,
canControlPlanesRead,
canKeyPolicyRead,
canNetworksRead,
canNodesRead,
canTokensWrite,
canUsersRead,
],
)
useEffect(() => {
if (canControlPlanesRead) {
refreshControlPlanes()
}
if (canNetworksRead) {
refreshNetworks()
}
if (canRolesRead) {
refreshRoles()
}
if (canUsersRead) {
refreshUsers()
}
}, [canControlPlanesRead, canNetworksRead, canRolesRead, canUsersRead])
useEffect(() => {
if (visibleSections.some((section) => section.id === active)) {
return
}
if (visibleSections.length > 0) {
setActive(visibleSections[0].id)
}
}, [active, visibleSections])
useEffect(() => {
if (controlPlanes.length && !selectedControlPlaneId) {
setSelectedControlPlaneId(controlPlanes[0].id)
}
}, [controlPlanes, selectedControlPlaneId])
useEffect(() => {
if (networks.length && !selectedNetworkId) {
setSelectedNetworkId(networks[0].id)
}
}, [networks, selectedNetworkId])
useEffect(() => {
if (!selectedNetworkId) return
if (canNodesRead) {
listNodes(selectedNetworkId)
.then(setNodesResponse)
.catch((err) => handleError(err, 'error'))
} else {
setNodesResponse(null)
}
if (canAclRead) {
getAcl(selectedNetworkId)
.then((policy) => setAclText(JSON.stringify(policy, null, 2)))
.catch(() => setAclText(''))
} else {
setAclText('')
}
if (canKeyPolicyRead) {
getKeyPolicy(selectedNetworkId)
.then(setKeyPolicy)
.catch(() => setKeyPolicy(null))
} else {
setKeyPolicy(null)
}
}, [selectedNetworkId, canAclRead, canKeyPolicyRead, canNodesRead])
useEffect(() => {
if (active !== 'audit' || !canAuditRead) return
listAdminAudit({ limit: 200 })
.then(setAdminAudit)
.catch((err) => handleError(err, 'error'))
}, [active, canAuditRead])
const refreshControlPlanes = async () => {
try {
const data = await listControlPlanes()
setControlPlanes(data)
} catch (err) {
handleError(err, 'error')
}
}
const refreshNetworks = async () => {
try {
const data = await listNetworks()
setNetworks(data)
} catch (err) {
handleError(err, 'error')
}
}
const refreshRoles = async () => {
try {
const data = await listRoles()
setRoles(data)
} catch (err) {
handleError(err, 'error')
}
}
const refreshUsers = async () => {
try {
const data = await listUsers()
setUsers(data)
} catch (err) {
handleError(err, 'error')
}
}
const handleError = (err: unknown, tone: BannerTone) => {
setBanner({ tone, text: (err as Error).message })
setTimeout(() => setBanner(null), 4000)
}
const handleLogout = async () => {
try {
await logout()
} finally {
onLogout()
}
}
const handleSelectUser = async (userId: string) => {
try {
const detail = await getUser(userId)
setSelectedUser(detail)
} catch (err) {
handleError(err, 'error')
}
}
const handleUpdateCurrentUser = async () => {
try {
const refreshed = await getMe()
if (!refreshed.console_access) {
onLogout()
return
}
onSessionUpdated(refreshed)
} catch (err) {
handleError(err, 'warning')
}
}
const handleNodeAction = async (action: () => Promise<unknown>) => {
try {
await action()
if (selectedNetworkId) {
const updated = await listNodes(selectedNetworkId)
setNodesResponse(updated)
}
} catch (err) {
handleError(err, 'error')
}
}
const handleAclSave = async () => {
if (!selectedNetworkId) return
try {
const parsed = JSON.parse(aclText)
await updateAcl(selectedNetworkId, parsed)
handleError(new Error('ACL updated.'), 'success')
} catch (err) {
handleError(err, 'error')
}
}
const handleKeyPolicySave = async (maxAge: number | null) => {
if (!selectedNetworkId) return
try {
const updated = await updateKeyPolicy(selectedNetworkId, {
max_age_seconds: maxAge || null,
})
setKeyPolicy(updated)
handleError(new Error('Key policy updated.'), 'success')
} catch (err) {
handleError(err, 'error')
}
}
return (
<div className="app-shell">
<aside className="sidebar">
<div className="brand-mini">
<span>LS</span>
<div>
<p>Lightscale</p>
<small>Admin console</small>
</div>
</div>
<nav className="nav">
{visibleSections.map((section) => (
<button
key={section.id}
className={active === section.id ? 'active' : ''}
onClick={() => setActive(section.id)}
>
<span>{section.label}</span>
<small>{section.hint}</small>
</button>
))}
</nav>
<div className="sidebar-footer">
<div>
<p>{user.display_name || user.email}</p>
<small>{user.super_admin ? 'Super admin' : 'Operator'}</small>
</div>
<button className="ghost" onClick={handleLogout}>
Sign out
</button>
</div>
</aside>
<main className="main">
<header className="topbar">
<div>
<h1>{visibleSections.find((section) => section.id === active)?.label || 'Overview'}</h1>
<p>
{selectedNetwork
? `${selectedNetwork.name} · ${selectedNetwork.control_plane_name}`
: 'Select a network to unlock node actions.'}
</p>
</div>
<div className="selector-row">
<label>
<span>Control plane</span>
<select
value={selectedControlPlaneId}
onChange={(event) => setSelectedControlPlaneId(event.target.value)}
>
<option value="">Select</option>
{controlPlanes.map((plane) => (
<option key={plane.id} value={plane.id}>
{plane.name}
</option>
))}
</select>
</label>
<label>
<span>Network</span>
<select
value={selectedNetworkId}
onChange={(event) => setSelectedNetworkId(event.target.value)}
>
<option value="">Select</option>
{networks.map((network) => (
<option key={network.id} value={network.id}>
{network.name}
</option>
))}
</select>
</label>
<button className="ghost" onClick={handleUpdateCurrentUser}>
Refresh
</button>
</div>
</header>
{banner && <div className={`banner ${banner.tone}`}>{banner.text}</div>}
<section className="content">
{active === 'overview' && (
<div className="grid two">
<div className="card highlight">
<h3>Control plane mesh</h3>
<p>{controlPlanes.length} control planes online.</p>
<div className="metrics">
<div>
<span>Networks</span>
<strong>{networks.length}</strong>
</div>
<div>
<span>Nodes (selected)</span>
<strong>{selectedNodeCount}</strong>
</div>
</div>
</div>
<div className="card">
<h3>Active signals</h3>
<p>Audit log for admin actions and control plane events.</p>
{canAuditRead ? (
<button className="ghost" onClick={() => setActive('audit')}>
View audit timeline
</button>
) : (
<p className="muted">No audit permission.</p>
)}
</div>
<div className="card">
<h3>Role coverage</h3>
<p>
{roles.length} roles configured. {users.length} users in
directory.
</p>
{canUsersRead ? (
<button className="ghost" onClick={() => setActive('users')}>
Manage access
</button>
) : (
<p className="muted">No user-directory permission.</p>
)}
</div>
<div className="card">
<h3>Network posture</h3>
<p>Keep ACLs tight and key rotation moving.</p>
{canAclRead ? (
<button className="ghost" onClick={() => setActive('acl')}>
Edit ACL
</button>
) : (
<p className="muted">No ACL permission.</p>
)}
</div>
</div>
)}
{active === 'control-planes' && canControlPlanesRead && (
<ControlPlaneSection
controlPlanes={controlPlanes}
canWrite={canControlPlanesWrite}
onCreate={async (payload) => {
const created = await createControlPlane(payload)
setControlPlanes((prev) => [...prev, created])
}}
onUpdate={async (id, payload) => {
const updated = await updateControlPlane(id, payload)
setControlPlanes((prev) =>
prev.map((plane) => (plane.id === updated.id ? updated : plane)),
)
}}
onVerify={async (id) => verifyControlPlane(id)}
onDelete={async (id) => {
await deleteControlPlane(id)
setControlPlanes((prev) => prev.filter((plane) => plane.id !== id))
}}
onBanner={setBanner}
/>
)}
{active === 'networks' && canNetworksRead && (
<NetworksSection
controlPlanes={controlPlanes}
networks={networks}
canWrite={canNetworksWrite}
onCreate={async (payload) => {
const result = await createNetwork(payload)
setNetworks((prev) => [...prev, result.network])
setBootstrapToken(result.bootstrap_token || null)
setSelectedNetworkId(result.network.id)
}}
onDelete={async (id) => {
await deleteNetwork(id)
setNetworks((prev) => prev.filter((network) => network.id !== id))
}}
onSelect={(id) => setSelectedNetworkId(id)}
selectedNetworkId={selectedNetworkId}
bootstrapToken={bootstrapToken}
/>
)}
{active === 'nodes' && canNodesRead && (
<NodesSection
network={selectedNetwork}
nodes={nodesResponse?.nodes || []}
keyHistory={keyHistory}
canWrite={canNodesWrite}
onApprove={(nodeId) =>
handleNodeAction(() => approveNode(selectedNetworkId, nodeId))
}
onRevoke={(nodeId) =>
handleNodeAction(() => revokeNode(selectedNetworkId, nodeId))
}
onRotate={(nodeId) =>
handleNodeAction(() => rotateNodeKeys(selectedNetworkId, nodeId, {}))
}
onViewKeys={async (nodeId) => {
if (!selectedNetworkId) return
try {
const history = await nodeKeys(selectedNetworkId, nodeId)
setKeyHistory(history)
} catch (err) {
handleError(err, 'error')
}
}}
/>
)}
{active === 'tokens' && canTokensWrite && (
<TokensSection
network={selectedNetwork}
tokenResult={tokenResult}
revokedToken={revokedToken}
onCreate={async (payload) => {
if (!selectedNetworkId) return
const response = await createToken(selectedNetworkId, payload)
setTokenResult(response)
setRevokedToken(null)
}}
onRevoke={async (tokenId) => {
if (!selectedNetworkId) return
const response = await revokeToken(selectedNetworkId, tokenId)
setRevokedToken(response)
}}
/>
)}
{active === 'acl' && canAclRead && (
<AclSection
network={selectedNetwork}
aclText={aclText}
canWrite={canAclWrite}
onChange={setAclText}
onSave={handleAclSave}
/>
)}
{active === 'key-policy' && canKeyPolicyRead && (
<KeyPolicySection
network={selectedNetwork}
policy={keyPolicy}
canWrite={canKeyPolicyWrite}
onSave={handleKeyPolicySave}
/>
)}
{active === 'users' && canUsersRead && (
<UsersSection
users={users}
roles={roles}
selectedUser={selectedUser}
canWrite={canUsersWrite}
onSelect={handleSelectUser}
onCreate={async (payload) => {
const created = await createUser(payload)
setUsers((prev) => [created.user, ...prev])
setSelectedUser(created)
}}
onUpdate={async (userId, payload) => {
const updated = await updateUser(userId, payload)
setUsers((prev) =>
prev.map((record) => (record.id === updated.id ? updated : record)),
)
if (selectedUser && selectedUser.user.id === updated.id) {
setSelectedUser({
...selectedUser,
user: updated,
})
}
}}
onSetPassword={async (userId, password) => {
await setUserPassword(userId, password)
}}
onAddMembership={async (userId, payload) => {
const membership = await addMembership(userId, payload)
if (selectedUser && selectedUser.user.id === userId) {
setSelectedUser({
...selectedUser,
memberships: [membership, ...selectedUser.memberships],
})
}
}}
onDeleteMembership={async (userId, membershipId) => {
await deleteMembership(userId, membershipId)
if (selectedUser && selectedUser.user.id === userId) {
setSelectedUser({
...selectedUser,
memberships: selectedUser.memberships.filter(
(membership) => membership.id !== membershipId,
),
})
}
}}
/>
)}
{active === 'audit' && canAuditRead && (
<AuditSection
controlPlanes={controlPlanes}
adminAudit={adminAudit}
controlPlaneAudit={controlPlaneAudit}
onRefreshAdmin={async () => {
const entries = await listAdminAudit({ limit: 200 })
setAdminAudit(entries)
}}
onFetchControlPlane={async (id, params) => {
const response = await listControlPlaneAudit(id, params)
setControlPlaneAudit(response)
}}
/>
)}
</section>
</main>
</div>
)
}
function ControlPlaneSection({
controlPlanes,
canWrite,
onCreate,
onUpdate,
onVerify,
onDelete,
onBanner,
}: {
controlPlanes: ControlPlaneSummary[]
canWrite: boolean
onCreate: (payload: {
name: string
base_url: string
admin_token?: string
region?: string
}) => Promise<void>
onUpdate: (
id: string,
payload: {
name?: string
base_url?: string
admin_token?: string
clear_admin_token?: boolean
region?: string
},
) => Promise<void>
onVerify: (id: string) => Promise<{ ok: boolean; status?: number; body?: string }>
onDelete: (id: string) => Promise<void>
onBanner: (message: BannerMessage) => void
}) {
const [form, setForm] = useState({
id: '',
name: '',
base_url: '',
admin_token: '',
region: '',
clear_admin_token: false,
})
const [loading, setLoading] = useState(false)
const resetForm = () => {
setForm({
id: '',
name: '',
base_url: '',
admin_token: '',
region: '',
clear_admin_token: false,
})
}
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
if (!canWrite) return
setLoading(true)
try {
if (form.id) {
await onUpdate(form.id, {
name: form.name,
base_url: form.base_url,
admin_token: form.admin_token || undefined,
clear_admin_token: form.clear_admin_token,
region: form.region || undefined,
})
onBanner({ tone: 'success', text: 'Control plane updated.' })
} else {
await onCreate({
name: form.name,
base_url: form.base_url,
admin_token: form.admin_token || undefined,
region: form.region || undefined,
})
onBanner({ tone: 'success', text: 'Control plane created.' })
}
resetForm()
} catch (err) {
onBanner({ tone: 'error', text: (err as Error).message })
} finally {
setLoading(false)
}
}
return (
<div className="grid two">
<div className="card">
<h3>Create or update control plane</h3>
<form onSubmit={handleSubmit} className="stack">
<label>
<span>Name</span>
<input
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
required
/>
</label>
<label>
<span>Base URL</span>
<input
value={form.base_url}
onChange={(event) => setForm({ ...form, base_url: event.target.value })}
placeholder="https://control-plane.example.com"
required
/>
</label>
<label>
<span>Admin token</span>
<input
value={form.admin_token}
onChange={(event) => setForm({ ...form, admin_token: event.target.value })}
placeholder={form.id ? 'Leave blank to keep current' : 'Optional'}
/>
</label>
{form.id && (
<label className="toggle">
<input
type="checkbox"
checked={form.clear_admin_token}
onChange={(event) =>
setForm({ ...form, clear_admin_token: event.target.checked })
}
/>
<span>Clear stored token</span>
</label>
)}
<label>
<span>Region</span>
<input
value={form.region}
onChange={(event) => setForm({ ...form, region: event.target.value })}
placeholder="ap-northeast-1"
/>
</label>
<div className="button-row">
<button type="submit" disabled={loading || !canWrite}>
{loading ? 'Saving...' : form.id ? 'Update plane' : 'Create plane'}
</button>
{form.id && (
<button type="button" className="ghost" onClick={resetForm}>
Cancel
</button>
)}
</div>
</form>
</div>
<div className="card">
<h3>Registered control planes</h3>
<div className="list">
{controlPlanes.map((plane) => (
<div key={plane.id} className="list-row">
<div>
<strong>{plane.name}</strong>
<p>{plane.base_url}</p>
<span className="tag">{plane.region || 'unassigned'}</span>
{plane.has_admin_token ? (
<span className="tag success">token stored</span>
) : (
<span className="tag">no token</span>
)}
</div>
<div className="button-row">
<button
className="ghost"
onClick={async () => {
const result = await onVerify(plane.id)
onBanner({
tone: result.ok ? 'success' : 'warning',
text: result.ok
? 'Health check OK.'
: `Health check failed (${result.status ?? 'no status'}).`,
})
}}
>
Verify
</button>
{canWrite && (
<>
<button
className="ghost"
onClick={() =>
setForm({
id: plane.id,
name: plane.name,
base_url: plane.base_url,
admin_token: '',
region: plane.region || '',
clear_admin_token: false,
})
}
>
Edit
</button>
<button
className="ghost danger"
onClick={() => {
if (window.confirm(`Delete control plane "${plane.name}"?`)) {
onDelete(plane.id)
}
}}
>
Delete
</button>
</>
)}
</div>
</div>
))}
{controlPlanes.length === 0 && (
<p className="muted">No control planes yet.</p>
)}
</div>
</div>
</div>
)
}
function NetworksSection({
controlPlanes,
networks,
canWrite,
onCreate,
onDelete,
onSelect,
selectedNetworkId,
bootstrapToken,
}: {
controlPlanes: ControlPlaneSummary[]
networks: NetworkSummary[]
canWrite: boolean
onCreate: (payload: {
control_plane_id: string
name: string
overlay_v4?: string
overlay_v6?: string
dns_domain?: string
requires_approval?: boolean
key_rotation_max_age_seconds?: number
bootstrap_token_ttl_seconds?: number
bootstrap_token_uses?: number
bootstrap_token_tags?: string[]
}) => Promise<void>
onDelete: (id: string) => Promise<void>
onSelect: (id: string) => void
selectedNetworkId: string
bootstrapToken: EnrollmentToken | null
}) {
const [form, setForm] = useState({
control_plane_id: '',
name: '',
overlay_v4: '',
overlay_v6: '',
dns_domain: '',
requires_approval: false,
key_rotation_max_age_seconds: '',
bootstrap_token_ttl_seconds: '',
bootstrap_token_uses: '1',
bootstrap_token_tags: '',
})
const handleSubmit = async (event: FormEvent) => {
event.preventDefault()
if (!canWrite) return
if (!form.control_plane_id) return
await onCreate({
control_plane_id: form.control_plane_id,
name: form.name,
overlay_v4: form.overlay_v4 || undefined,
overlay_v6: form.overlay_v6 || undefined,
dns_domain: form.dns_domain || undefined,
requires_approval: form.requires_approval,
key_rotation_max_age_seconds: form.key_rotation_max_age_seconds
? Number(form.key_rotation_max_age_seconds)
: undefined,
bootstrap_token_ttl_seconds: form.bootstrap_token_ttl_seconds
? Number(form.bootstrap_token_ttl_seconds)
: undefined,
bootstrap_token_uses: form.bootstrap_token_uses
? Number(form.bootstrap_token_uses)
: undefined,
bootstrap_token_tags: form.bootstrap_token_tags
? form.bootstrap_token_tags.split(',').map((tag) => tag.trim())
: undefined,
})
setForm({
control_plane_id: form.control_plane_id,
name: '',
overlay_v4: '',
overlay_v6: '',
dns_domain: '',
requires_approval: false,
key_rotation_max_age_seconds: '',
bootstrap_token_ttl_seconds: '',
bootstrap_token_uses: '1',
bootstrap_token_tags: '',
})
}
return (
<div className="grid two">
<div className="card">
<h3>Provision a network</h3>
<form onSubmit={handleSubmit} className="stack">
<label>
<span>Control plane</span>
<select
value={form.control_plane_id}
onChange={(event) =>
setForm({ ...form, control_plane_id: event.target.value })
}
required
>
<option value="">Select</option>
{controlPlanes.map((plane) => (
<option key={plane.id} value={plane.id}>
{plane.name}
</option>
))}
</select>
</label>
<label>
<span>Network name</span>
<input
value={form.name}
onChange={(event) => setForm({ ...form, name: event.target.value })}
required
/>
</label>
<div className="inline">
<label>
<span>Overlay IPv4 CIDR</span>
<input
value={form.overlay_v4}
onChange={(event) =>
setForm({ ...form, overlay_v4: event.target.value })
}
placeholder="100.120.0.0/24"
/>
</label>
<label>
<span>Overlay IPv6 CIDR</span>
<input
value={form.overlay_v6}
onChange={(event) =>
setForm({ ...form, overlay_v6: event.target.value })
}
placeholder="fd42:120:0::/48"
/>
</label>
</div>
<label>
<span>DNS domain</span>
<input
value={form.dns_domain}
onChange={(event) =>
setForm({ ...form, dns_domain: event.target.value })
}
placeholder="optional"
/>
</label>
<label className="toggle">
<input
type="checkbox"
checked={form.requires_approval}
onChange={(event) =>
setForm({ ...form, requires_approval: event.target.checked })
}
/>
<span>Requires approval</span>
</label>
<div className="inline">
<label>
<span>Key rotation (sec)</span>
<input
type="number"
value={form.key_rotation_max_age_seconds}
onChange={(event) =>
setForm({
...form,
key_rotation_max_age_seconds: event.target.value,
})
}
/>
</label>
<label>
<span>Bootstrap TTL</span>
<input
type="number"
value={form.bootstrap_token_ttl_seconds}
onChange={(event) =>
setForm({
...form,
bootstrap_token_ttl_seconds: event.target.value,
})
}
/>
</label>
</div>
<div className="inline">
<label>
<span>Bootstrap uses</span>
<input
type="number"
value={form.bootstrap_token_uses}
onChange={(event) =>
setForm({ ...form, bootstrap_token_uses: event.target.value })
}
/>
</label>
<label>
<span>Bootstrap tags</span>
<input
value={form.bootstrap_token_tags}
onChange={(event) =>
setForm({ ...form, bootstrap_token_tags: event.target.value })
}
placeholder="tag1, tag2"
/>
</label>
</div>
<button type="submit" disabled={!canWrite}>Create network</button>
</form>
{bootstrapToken && (
<div className="callout">
<p className="section-title">Bootstrap token</p>
<code>{bootstrapToken.token}</code>
<div className="meta">
<span>Uses left: {bootstrapToken.uses_left}</span>
<span>Expires: {formatEpoch(bootstrapToken.expires_at)}</span>
</div>
</div>
)}
</div>
<div className="card">
<h3>Known networks</h3>
<div className="list">
{networks.map((network, index) => (
<div
key={network.id}
className={`list-row ${
network.id === selectedNetworkId ? 'active' : ''
}`}
style={{ animationDelay: `${index * 40}ms` }}
>
<div
className="selectable-area"
onClick={() => onSelect(network.id)}
>
<strong>{network.name}</strong>
<p>{network.network_id}</p>
<span className="tag">{network.control_plane_name}</span>
{network.requires_approval && (
<span className="tag warning">approval required</span>
)}
</div>
<div className="mono">{network.overlay_v4 || '-'}</div>
<div className="button-row">
{canWrite && (
<button
className="ghost danger"
onClick={() => {
if (window.confirm(`Delete network "${network.name}"?`)) {
onDelete(network.id)
}
}}
>
Delete
</button>
)}
</div>
</div>
))}
{networks.length === 0 && <p className="muted">No networks yet.</p>}
</div>
</div>
</div>
)
}
function NodesSection({
network,
nodes,
keyHistory,
canWrite,
onApprove,
onRevoke,
onRotate,
onViewKeys,
}: {
network: NetworkSummary | null
nodes: NodeInfo[]
keyHistory: KeyHistoryResponse | null
canWrite: boolean
onApprove: (nodeId: string) => void
onRevoke: (nodeId: string) => void
onRotate: (nodeId: string) => void
onViewKeys: (nodeId: string) => void
}) {
if (!network) {
return <div className="card">Select a network to view nodes.</div>
}
return (
<div className="grid two">
<div className="card">
<h3>Nodes in {network.name}</h3>
<div className="list">
{nodes.map((node) => (
<div key={node.id} className="list-row">
<div>
<strong>{node.name}</strong>
<p>{node.dns_name}</p>
<div className="tag-row">
{node.approved ? (
<span className="tag success">approved</span>
) : (
<span className="tag warning">pending</span>
)}
{node.owner_is_admin && <span className="tag">admin-issued</span>}
{node.revoked && <span className="tag danger">revoked</span>}
{node.key_rotation_required && (
<span className="tag warning">rotate keys</span>
)}
</div>
{node.owner_email && <small>Owner: {node.owner_email}</small>}
<small>Last seen: {formatEpoch(node.last_seen)}</small>
</div>
<div className="button-row">
{canWrite && !node.approved && !node.revoked && (
<button onClick={() => onApprove(node.id)}>Approve</button>
)}
{canWrite && !node.revoked && (
<button className="ghost" onClick={() => onRevoke(node.id)}>
Revoke
</button>
)}
{canWrite && (
<button className="ghost" onClick={() => onRotate(node.id)}>
Rotate keys
</button>
)}
<button className="ghost" onClick={() => onViewKeys(node.id)}>
View keys
</button>
</div>
</div>
))}
{nodes.length === 0 && <p className="muted">No nodes yet.</p>}
</div>
</div>
<div className="card">
<h3>Key history</h3>
{keyHistory ? (
<div className="list">
{keyHistory.keys.map((key) => (
<div key={`${key.key_type}-${key.created_at}`} className="list-row">
<div>
<strong>{key.key_type}</strong>
<p className="mono">{key.public_key}</p>
</div>
<div>
<span className="tag">{formatEpoch(key.created_at)}</span>
{key.revoked_at && (
<span className="tag danger">
revoked {formatEpoch(key.revoked_at)}
</span>
)}
</div>
</div>
))}
</div>
) : (
<p className="muted">Select a node to view key history.</p>
)}
</div>
</div>
)
}
function TokensSection({
network,
tokenResult,
revokedToken,
onCreate,
onRevoke,
}: {
network: NetworkSummary | null
tokenResult: CreateTokenResponse | null
revokedToken: EnrollmentToken | null
onCreate: (payload: { ttl_seconds: number; uses: number; tags: string[] }) => void
onRevoke: (tokenId: string) => void
}) {
const [form, setForm] = useState({ ttl_seconds: '3600', uses: '1', tags: '' })
const [revokeId, setRevokeId] = useState('')
if (!network) {
return <div className="card">Select a network to manage tokens.</div>
}
return (
<div className="grid two">
<div className="card">
<h3>Create enrollment token</h3>
<form
onSubmit={(event) => {
event.preventDefault()
onCreate({
ttl_seconds: Number(form.ttl_seconds),
uses: Number(form.uses),
tags: form.tags
? form.tags.split(',').map((tag) => tag.trim())
: [],
})
}}
className="stack"
>
<label>
<span>TTL seconds</span>
<input
type="number"
value={form.ttl_seconds}
onChange={(event) =>
setForm({ ...form, ttl_seconds: event.target.value })
}
/>
</label>
<label>
<span>Uses</span>
<input
type="number"
value={form.uses}
onChange={(event) => setForm({ ...form, uses: event.target.value })}
/>
</label>
<label>
<span>Tags</span>
<input
value={form.tags}
onChange={(event) => setForm({ ...form, tags: event.target.value })}
placeholder="tag1, tag2"
/>
</label>
<button type="submit">Generate token</button>
</form>
{tokenResult && (
<div className="callout">
<p className="section-title">Generated token</p>
<code>{tokenResult.token.token}</code>
<div className="meta">
<span>Uses left: {tokenResult.token.uses_left}</span>
<span>Expires: {formatEpoch(tokenResult.token.expires_at)}</span>
</div>
{(tokenResult.token.owner_email || tokenResult.token.owner_is_admin) && (
<div className="meta">
{tokenResult.token.owner_email && (
<span>Owner: {tokenResult.token.owner_email}</span>
)}
{tokenResult.token.owner_is_admin && <span>Privilege: admin</span>}
</div>
)}
</div>
)}
</div>
<div className="card">
<h3>Revoke token</h3>
<form
onSubmit={(event) => {
event.preventDefault()
onRevoke(revokeId)
}}
className="stack"
>
<label>
<span>Token ID</span>
<input
value={revokeId}
onChange={(event) => setRevokeId(event.target.value)}
/>
</label>
<button type="submit" className="ghost">
Revoke token
</button>
</form>
{revokedToken && (
<div className="callout">
<p className="section-title">Revoked token</p>
<code>{revokedToken.token}</code>
<span className="tag danger">revoked</span>
</div>
)}
</div>
</div>
)
}
function AclSection({
network,
aclText,
canWrite,
onChange,
onSave,
}: {
network: NetworkSummary | null
aclText: string
canWrite: boolean
onChange: (value: string) => void
onSave: () => void
}) {
if (!network) {
return <div className="card">Select a network to edit ACL.</div>
}
return (
<div className="card">
<h3>ACL policy for {network.name}</h3>
<textarea
value={aclText}
onChange={(event) => onChange(event.target.value)}
disabled={!canWrite}
/>
<div className="button-row">
<button onClick={onSave} disabled={!canWrite}>
Save policy
</button>
</div>
</div>
)
}
function KeyPolicySection({
network,
policy,
canWrite,
onSave,
}: {
network: NetworkSummary | null
policy: KeyPolicyResponse | null
canWrite: boolean
onSave: (maxAge: number | null) => void
}) {
const [maxAge, setMaxAge] = useState('')
useEffect(() => {
if (policy?.policy.max_age_seconds) {
setMaxAge(String(policy.policy.max_age_seconds))
} else {
setMaxAge('')
}
}, [policy])
if (!network) {
return <div className="card">Select a network to edit key policy.</div>
}
return (
<div className="card">
<h3>Key rotation policy</h3>
<label>
<span>Max age (seconds)</span>
<input
type="number"
value={maxAge}
onChange={(event) => setMaxAge(event.target.value)}
disabled={!canWrite}
/>
</label>
<div className="button-row">
<button
onClick={() => onSave(maxAge ? Number(maxAge) : null)}
disabled={!canWrite}
>
Save policy
</button>
</div>
</div>
)
}
function UsersSection({
users,
roles,
selectedUser,
canWrite,
onSelect,
onCreate,
onUpdate,
onSetPassword,
onAddMembership,
onDeleteMembership,
}: {
users: UserSummary[]
roles: RoleSummary[]
selectedUser: UserDetail | null
canWrite: boolean
onSelect: (id: string) => void
onCreate: (payload: {
email: string
display_name?: string
password: string
role_id?: string
super_admin?: boolean
}) => Promise<void>
onUpdate: (
userId: string,
payload: {
display_name?: string | null
disabled?: boolean
super_admin?: boolean
},
) => Promise<void>
onSetPassword: (userId: string, password: string) => Promise<void>
onAddMembership: (
userId: string,
payload: { role_id: string; scope_type: string; scope_id: string },
) => Promise<void>
onDeleteMembership: (userId: string, membershipId: string) => Promise<void>
}) {
const [createForm, setCreateForm] = useState({
email: '',
display_name: '',
password: '',
role_id: '',
super_admin: false,
})
const [updateForm, setUpdateForm] = useState({
display_name: '',
disabled: false,
super_admin: false,
})
const [password, setPassword] = useState('')
const [membershipForm, setMembershipForm] = useState({
role_id: '',
scope_type: DEFAULT_SCOPE.scope_type,
scope_id: DEFAULT_SCOPE.scope_id,
})
useEffect(() => {
if (!selectedUser) return
setUpdateForm({
display_name: selectedUser.user.display_name || '',
disabled: selectedUser.user.disabled,
super_admin: selectedUser.user.super_admin,
})
}, [selectedUser])
return (
<div className="grid two">
<div className="card">
<h3>Create user</h3>
<form
onSubmit={(event) => {
event.preventDefault()
if (!canWrite) return
onCreate({
email: createForm.email,
display_name: createForm.display_name || undefined,
password: createForm.password,
role_id: createForm.role_id || undefined,
super_admin: createForm.super_admin,
})
setCreateForm({
email: '',
display_name: '',
password: '',
role_id: createForm.role_id,
super_admin: false,
})
}}
className="stack"
>
<label>
<span>Email</span>
<input
value={createForm.email}
onChange={(event) =>
setCreateForm({ ...createForm, email: event.target.value })
}
required
/>
</label>
<label>
<span>Display name</span>
<input
value={createForm.display_name}
onChange={(event) =>
setCreateForm({ ...createForm, display_name: event.target.value })
}
/>
</label>
<label>
<span>Password</span>
<input
type="password"
value={createForm.password}
onChange={(event) =>
setCreateForm({ ...createForm, password: event.target.value })
}
required
/>
</label>
<label>
<span>Role</span>
<select
value={createForm.role_id}
onChange={(event) =>
setCreateForm({ ...createForm, role_id: event.target.value })
}
>
<option value="">Viewer (default)</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</label>
<label className="toggle">
<input
type="checkbox"
checked={createForm.super_admin}
onChange={(event) =>
setCreateForm({ ...createForm, super_admin: event.target.checked })
}
/>
<span>Super admin</span>
</label>
<button type="submit" disabled={!canWrite}>Create user</button>
{!canWrite && <p className="muted">Read-only.</p>}
</form>
</div>
<div className="card">
<h3>User directory</h3>
<div className="list">
{users.map((record) => (
<button
key={record.id}
className={`list-row selectable ${
selectedUser?.user.id === record.id ? 'active' : ''
}`}
onClick={() => onSelect(record.id)}
>
<div>
<strong>{record.display_name || record.email}</strong>
<p>{record.email}</p>
<div className="tag-row">
{record.super_admin && <span className="tag">super admin</span>}
{record.disabled && <span className="tag danger">disabled</span>}
</div>
</div>
<div className="meta">
<span>{formatDate(record.created_at)}</span>
</div>
</button>
))}
{users.length === 0 && <p className="muted">No users yet.</p>}
</div>
</div>
<div className="card">
<h3>User detail</h3>
{selectedUser ? (
<div className="stack">
<label>
<span>Display name</span>
<input
value={updateForm.display_name}
onChange={(event) =>
setUpdateForm({ ...updateForm, display_name: event.target.value })
}
/>
</label>
<label className="toggle">
<input
type="checkbox"
checked={updateForm.disabled}
onChange={(event) =>
setUpdateForm({ ...updateForm, disabled: event.target.checked })
}
/>
<span>Disabled</span>
</label>
<label className="toggle">
<input
type="checkbox"
checked={updateForm.super_admin}
onChange={(event) =>
setUpdateForm({ ...updateForm, super_admin: event.target.checked })
}
/>
<span>Super admin</span>
</label>
<button
disabled={!canWrite}
onClick={() =>
onUpdate(selectedUser.user.id, {
display_name: updateForm.display_name,
disabled: updateForm.disabled,
super_admin: updateForm.super_admin,
})
}
>
Save changes
</button>
<div className="divider" />
<label>
<span>Reset password</span>
<input
type="password"
value={password}
onChange={(event) => setPassword(event.target.value)}
/>
</label>
<button
className="ghost"
disabled={!canWrite}
onClick={() => {
if (!password) return
onSetPassword(selectedUser.user.id, password)
setPassword('')
}}
>
Update password
</button>
</div>
) : (
<p className="muted">Select a user to manage.</p>
)}
</div>
<div className="card">
<h3>Memberships</h3>
{selectedUser ? (
<div className="stack">
<div className="list">
{selectedUser.memberships.map((membership) => (
<div key={membership.id} className="list-row">
<div>
<strong>{membership.role_name}</strong>
<p>
{membership.scope_type}:{membership.scope_id}
</p>
</div>
<button
className="ghost"
disabled={!canWrite}
onClick={() =>
onDeleteMembership(selectedUser.user.id, membership.id)
}
>
Remove
</button>
</div>
))}
{selectedUser.memberships.length === 0 && (
<p className="muted">No memberships yet.</p>
)}
</div>
<form
onSubmit={(event) => {
event.preventDefault()
if (!canWrite) return
if (!membershipForm.role_id) return
onAddMembership(selectedUser.user.id, membershipForm)
}}
className="stack"
>
<label>
<span>Role</span>
<select
value={membershipForm.role_id}
onChange={(event) =>
setMembershipForm({
...membershipForm,
role_id: event.target.value,
})
}
>
<option value="">Select role</option>
{roles.map((role) => (
<option key={role.id} value={role.id}>
{role.name}
</option>
))}
</select>
</label>
<div className="inline">
<label>
<span>Scope type</span>
<input
value={membershipForm.scope_type}
onChange={(event) =>
setMembershipForm({
...membershipForm,
scope_type: event.target.value,
})
}
/>
</label>
<label>
<span>Scope ID</span>
<input
value={membershipForm.scope_id}
onChange={(event) =>
setMembershipForm({
...membershipForm,
scope_id: event.target.value,
})
}
/>
</label>
</div>
<button type="submit" disabled={!canWrite}>Add membership</button>
</form>
</div>
) : (
<p className="muted">Select a user to manage memberships.</p>
)}
</div>
</div>
)
}
function AuditSection({
controlPlanes,
adminAudit,
controlPlaneAudit,
onRefreshAdmin,
onFetchControlPlane,
}: {
controlPlanes: ControlPlaneSummary[]
adminAudit: AdminAuditEntry[]
controlPlaneAudit: AuditLogResponse | null
onRefreshAdmin: () => Promise<void>
onFetchControlPlane: (
id: string,
params: { network_id?: string; node_id?: string; limit?: number },
) => Promise<void>
}) {
const [mode, setMode] = useState<'admin' | 'control-plane'>('admin')
const [selectedPlaneId, setSelectedPlaneId] = useState('')
const [networkId, setNetworkId] = useState('')
const [nodeId, setNodeId] = useState('')
return (
<div className="grid two">
<div className="card">
<h3>Audit mode</h3>
<div className="pill-row">
<button
className={mode === 'admin' ? 'pill active' : 'pill'}
onClick={() => setMode('admin')}
>
Admin audit
</button>
<button
className={mode === 'control-plane' ? 'pill active' : 'pill'}
onClick={() => setMode('control-plane')}
>
Control plane audit
</button>
</div>
{mode === 'admin' ? (
<div className="stack">
<p className="muted">Admin audit shows UI actions only.</p>
<button className="ghost" onClick={onRefreshAdmin}>
Refresh admin audit
</button>
</div>
) : (
<form
onSubmit={(event) => {
event.preventDefault()
if (!selectedPlaneId) return
onFetchControlPlane(selectedPlaneId, {
network_id: networkId || undefined,
node_id: nodeId || undefined,
limit: 200,
})
}}
className="stack"
>
<label>
<span>Control plane</span>
<select
value={selectedPlaneId}
onChange={(event) => setSelectedPlaneId(event.target.value)}
required
>
<option value="">Select</option>
{controlPlanes.map((plane) => (
<option key={plane.id} value={plane.id}>
{plane.name}
</option>
))}
</select>
</label>
<label>
<span>Network ID (optional)</span>
<input
value={networkId}
onChange={(event) => setNetworkId(event.target.value)}
/>
</label>
<label>
<span>Node ID (optional)</span>
<input
value={nodeId}
onChange={(event) => setNodeId(event.target.value)}
/>
</label>
<button type="submit">Fetch control plane audit</button>
</form>
)}
</div>
<div className="card">
<h3>{mode === 'admin' ? 'Admin audit log' : 'Control plane audit log'}</h3>
<div className="list">
{mode === 'admin'
? adminAudit.map((entry) => (
<div key={entry.id} className="list-row">
<div>
<strong>{entry.action}</strong>
<p>
{entry.actor_email || 'system'} ·{' '}
{entry.target_type || 'n/a'} {entry.target_id || ''}
</p>
</div>
<span className="tag">{formatDate(entry.created_at)}</span>
</div>
))
: controlPlaneAudit?.entries.map((entry) => (
<div key={entry.id} className="list-row">
<div>
<strong>{entry.action}</strong>
<p>
{entry.network_id || 'network'} ·{' '}
{entry.node_id || 'node'}
</p>
</div>
<span className="tag">{formatEpoch(entry.timestamp)}</span>
</div>
))}
</div>
</div>
</div>
)
}
function formatEpoch(seconds: number | null | undefined) {
if (!seconds) return '-'
return new Date(seconds * 1000).toLocaleString()
}
function formatDate(iso: string | null | undefined) {
if (!iso) return '-'
return new Date(iso).toLocaleString()
}
export default App