Add Mesh section to Settings for configuring/testing the mesh gate (#34)
* Add mesh prerequisite gate (NetBird verification before app config) Implements the design in docs/mesh-prerequisite-gate.md per the user's DECIDE A-D answers: a permanent admin override, B1 (reachable) verification with host mesh IP shown informationally, members allowed in with a notice instead of being blocked, and mesh.required defaulting off so the live production instance is unaffected. - system_config kv table + getConfig/setConfig helpers - /api/system/mesh-status, /mesh/verify, /mesh/override, /mesh/required - AuthContext gains a 'needs-mesh' status (admins only) and exposes meshStatus for a member-facing banner - MeshGate page reuses the integration create+test flow to connect NetBird * Make mesh verification universal (CIDR check, not NetBird-specific) Replace the NetBird-adapter-based "reachable" check with a vendor-agnostic one: the admin supplies the mesh's IP range (CIDR), and verification just confirms this host has an address inside it. Works identically for NetBird, WireGuard, ZeroTier, Tailscale, or any other mesh tech, with no integration record or vendor API call required. * Add reachability fallback for routed meshes (VPC peering, etc.) A host can be on the mesh's "side" of a routed network (e.g. a VPC peered into a NetBird/WireGuard mesh) without holding a local IP in the mesh's own CIDR. Local-IP-in-CIDR stays the primary check; if it fails, the admin can supply a known peer/gateway IP on the mesh and we verify by pinging it instead. Adds iputils to the backend image for the ping binary. * Add Mesh section to Settings for configuring/testing the mesh gate Admins can now toggle mesh.required, run verify/override, and see current mesh status entirely from the app, without hitting the API directly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019hu9pZvJY4BgmcQeAw2ugk --------- Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
parent
70f88efdc8
commit
fcac50cc02
1 changed files with 173 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
|||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useSearchParams } from 'react-router-dom'
|
||||
import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser } from '../lib/api'
|
||||
import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser, type MeshStatus } from '../lib/api'
|
||||
import { useAuth } from '../lib/AuthContext'
|
||||
import {
|
||||
User,
|
||||
|
|
@ -23,6 +23,7 @@ import {
|
|||
LogOut,
|
||||
Users,
|
||||
UserPlus,
|
||||
Network,
|
||||
} from 'lucide-react'
|
||||
|
||||
const navSections = [
|
||||
|
|
@ -30,6 +31,7 @@ const navSections = [
|
|||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||
{ id: 'security', label: 'Security', icon: Shield },
|
||||
{ id: 'users', label: 'Users', icon: Users, adminOnly: true },
|
||||
{ id: 'mesh', label: 'Mesh', icon: Network, adminOnly: true },
|
||||
{ id: 'integrations', label: 'Integrations', icon: Plug },
|
||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||
{ id: 'data', label: 'Data & Backup', icon: Database },
|
||||
|
|
@ -1804,11 +1806,181 @@ function UsersSection() {
|
|||
)
|
||||
}
|
||||
|
||||
function MeshSection() {
|
||||
const [status, setStatus] = useState<MeshStatus | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [toggling, setToggling] = useState(false)
|
||||
|
||||
const [cidr, setCidr] = useState('')
|
||||
const [testIp, setTestIp] = useState('')
|
||||
const [verifying, setVerifying] = useState(false)
|
||||
const [verifyResult, setVerifyResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null)
|
||||
const [overriding, setOverriding] = useState(false)
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
const s = await api.getMeshStatus()
|
||||
setStatus(s)
|
||||
setCidr(s.cidr ?? '')
|
||||
setError('')
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to load mesh status')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, [])
|
||||
|
||||
async function handleToggle() {
|
||||
if (!status) return
|
||||
setToggling(true)
|
||||
try {
|
||||
const s = await api.setMeshRequired(!status.required)
|
||||
setStatus(s)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to update mesh requirement')
|
||||
} finally {
|
||||
setToggling(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleVerify() {
|
||||
setError('')
|
||||
setVerifyResult(null)
|
||||
setVerifying(true)
|
||||
try {
|
||||
const result = await api.verifyMesh(cidr, testIp || undefined)
|
||||
setVerifyResult(result)
|
||||
load()
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to verify mesh')
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleOverride() {
|
||||
setOverriding(true)
|
||||
try {
|
||||
const s = await api.overrideMesh()
|
||||
setStatus(s)
|
||||
} catch (err) {
|
||||
setError(err instanceof ApiError ? err.message : 'Failed to skip mesh setup')
|
||||
} finally {
|
||||
setOverriding(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div style={cardBase}>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading…</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-5">
|
||||
<div style={cardBase}>
|
||||
<div className="flex items-center justify-between" style={{ marginBottom: '14px' }}>
|
||||
<h3 style={{ ...sectionTitle, marginBottom: 0 }}>Mesh Network Gate</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{status?.required ? 'Required' : 'Not required'}</span>
|
||||
<Toggle on={!!status?.required} onClick={handleToggle} />
|
||||
</div>
|
||||
</div>
|
||||
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '14px' }}>
|
||||
When enabled, admins must verify this host is on a private mesh network (NetBird, WireGuard,
|
||||
ZeroTier, Tailscale, etc.) before accessing the rest of the app. Members are never blocked — they
|
||||
just see a notice banner until an admin finishes verification.
|
||||
</p>
|
||||
{toggling && <p style={{ fontSize: '11px', color: '#7A7D85' }}>Updating…</p>}
|
||||
|
||||
{status && (
|
||||
<div
|
||||
className="flex flex-col gap-1"
|
||||
style={{ marginBottom: '4px', padding: '12px', borderRadius: '8px', backgroundColor: 'rgba(255,255,255,0.02)', border: '1px solid rgba(255,255,255,0.06)' }}
|
||||
>
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>
|
||||
Verified: <span style={{ color: status.verified ? '#2ECC71' : '#E8E6E0' }}>{status.verified ? 'Yes' : 'No'}</span>
|
||||
{status.verifiedVia && ` (${status.verifiedVia})`}
|
||||
</p>
|
||||
{status.cidr && <p style={{ fontSize: '11px', color: '#7A7D85' }}>CIDR: {status.cidr}</p>}
|
||||
{status.hostMeshIp && <p style={{ fontSize: '11px', color: '#7A7D85' }}>Host mesh IP: {status.hostMeshIp}</p>}
|
||||
<p style={{ fontSize: '11px', color: '#7A7D85' }}>
|
||||
Override: <span style={{ color: status.overridden ? '#E67E22' : '#E8E6E0' }}>{status.overridden ? 'Active (gate skipped)' : 'None'}</span>
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={cardBase}>
|
||||
<h3 style={sectionTitle}>Configure & Verify</h3>
|
||||
<label style={labelStyle}>Mesh Network CIDR</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={cidr}
|
||||
onChange={(e) => setCidr(e.target.value)}
|
||||
placeholder="e.g. 100.64.0.0/10 (NetBird/Tailscale CGNAT range)"
|
||||
/>
|
||||
<label style={{ ...labelStyle, marginTop: '12px' }}>Peer/Gateway IP on the mesh (optional)</label>
|
||||
<input
|
||||
style={inputStyle}
|
||||
value={testIp}
|
||||
onChange={(e) => setTestIp(e.target.value)}
|
||||
placeholder="e.g. 100.64.0.1 — only needed if this host's own IP isn't in the mesh range"
|
||||
/>
|
||||
<p style={{ fontSize: '10px', color: '#5C5F66', marginTop: '6px' }}>
|
||||
If this host reaches the mesh through routing instead of holding a local mesh IP (e.g. a VPC
|
||||
peered into the mesh), give us an address on the mesh we can ping to confirm reachability.
|
||||
</p>
|
||||
|
||||
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||
{verifyResult && (
|
||||
<p style={{ fontSize: '12px', color: verifyResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>
|
||||
{verifyResult.message}
|
||||
{verifyResult.hostMeshIp && ` — host mesh IP: ${verifyResult.hostMeshIp}`}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between" style={{ marginTop: '16px' }}>
|
||||
<GoldButton onClick={handleVerify} disabled={verifying || !cidr}>
|
||||
{verifying ? 'Verifying…' : 'Verify Connection'}
|
||||
</GoldButton>
|
||||
<button
|
||||
onClick={handleOverride}
|
||||
disabled={overriding}
|
||||
className="cursor-pointer"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: '#7A7D85',
|
||||
background: 'transparent',
|
||||
border: '1px solid rgba(255,255,255,0.12)',
|
||||
borderRadius: '6px',
|
||||
padding: '8px 14px',
|
||||
opacity: overriding ? 0.6 : 1,
|
||||
}}
|
||||
>
|
||||
{overriding ? 'Skipping…' : 'Skip for now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const sectionComponents: Record<string, () => React.ReactElement> = {
|
||||
profile: ProfileSection,
|
||||
appearance: AppearanceSection,
|
||||
security: SecuritySection,
|
||||
users: UsersSection,
|
||||
mesh: MeshSection,
|
||||
integrations: IntegrationsSection,
|
||||
notifications: NotificationsSection,
|
||||
data: DataBackupSection,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue