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
This commit is contained in:
parent
800072ffbb
commit
4a4a5a01b3
1 changed files with 173 additions and 1 deletions
|
|
@ -1,6 +1,6 @@
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useSearchParams } from 'react-router-dom'
|
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 { useAuth } from '../lib/AuthContext'
|
||||||
import {
|
import {
|
||||||
User,
|
User,
|
||||||
|
|
@ -23,6 +23,7 @@ import {
|
||||||
LogOut,
|
LogOut,
|
||||||
Users,
|
Users,
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
Network,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
|
|
||||||
const navSections = [
|
const navSections = [
|
||||||
|
|
@ -30,6 +31,7 @@ const navSections = [
|
||||||
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
{ id: 'appearance', label: 'Appearance', icon: Palette },
|
||||||
{ id: 'security', label: 'Security', icon: Shield },
|
{ id: 'security', label: 'Security', icon: Shield },
|
||||||
{ id: 'users', label: 'Users', icon: Users, adminOnly: true },
|
{ id: 'users', label: 'Users', icon: Users, adminOnly: true },
|
||||||
|
{ id: 'mesh', label: 'Mesh', icon: Network, adminOnly: true },
|
||||||
{ id: 'integrations', label: 'Integrations', icon: Plug },
|
{ id: 'integrations', label: 'Integrations', icon: Plug },
|
||||||
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
{ id: 'notifications', label: 'Notifications', icon: Bell },
|
||||||
{ id: 'data', label: 'Data & Backup', icon: Database },
|
{ 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> = {
|
const sectionComponents: Record<string, () => React.ReactElement> = {
|
||||||
profile: ProfileSection,
|
profile: ProfileSection,
|
||||||
appearance: AppearanceSection,
|
appearance: AppearanceSection,
|
||||||
security: SecuritySection,
|
security: SecuritySection,
|
||||||
users: UsersSection,
|
users: UsersSection,
|
||||||
|
mesh: MeshSection,
|
||||||
integrations: IntegrationsSection,
|
integrations: IntegrationsSection,
|
||||||
notifications: NotificationsSection,
|
notifications: NotificationsSection,
|
||||||
data: DataBackupSection,
|
data: DataBackupSection,
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue