2101 lines
64 KiB
TypeScript
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
|