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
This commit is contained in:
parent
cdd93f204e
commit
46d95fca61
7 changed files with 342 additions and 4 deletions
|
|
@ -112,8 +112,28 @@ db.exec(`
|
||||||
reported_at TEXT,
|
reported_at TEXT,
|
||||||
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
received_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS system_config (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL,
|
||||||
|
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||||
|
);
|
||||||
`)
|
`)
|
||||||
|
|
||||||
|
export function getConfig(key: string): string | undefined {
|
||||||
|
const row = db.prepare('SELECT value FROM system_config WHERE key = ?').get(key) as
|
||||||
|
| { value: string }
|
||||||
|
| undefined
|
||||||
|
return row?.value
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setConfig(key: string, value: string) {
|
||||||
|
db.prepare(
|
||||||
|
`INSERT INTO system_config (key, value, updated_at) VALUES (?, ?, datetime('now'))
|
||||||
|
ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = datetime('now')`
|
||||||
|
).run(key, value)
|
||||||
|
}
|
||||||
|
|
||||||
export function logEvent(type: string, title: string, source?: string | null) {
|
export function logEvent(type: string, title: string, source?: string | null) {
|
||||||
db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null)
|
db.prepare('INSERT INTO events (type, title, source) VALUES (?, ?, ?)').run(type, title, source ?? null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
96
backend/src/routes/system.ts
Normal file
96
backend/src/routes/system.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
||||||
|
import type { FastifyInstance } from 'fastify'
|
||||||
|
import { networkInterfaces } from 'node:os'
|
||||||
|
import { z } from 'zod'
|
||||||
|
import { db, getConfig, setConfig, logEvent } from '../db/index.js'
|
||||||
|
import { loadSecrets } from '../db/secrets.js'
|
||||||
|
import { adapterRegistry } from '../integrations/registry.js'
|
||||||
|
import type { IntegrationType } from '../integrations/types.js'
|
||||||
|
|
||||||
|
interface IntegrationRow {
|
||||||
|
id: number
|
||||||
|
type: string
|
||||||
|
name: string
|
||||||
|
config_json: string
|
||||||
|
}
|
||||||
|
|
||||||
|
/** NetBird's default CGNAT mesh range (100.64.0.0/10) — informational only (DECIDE B2). */
|
||||||
|
function detectHostMeshIp(): string | null {
|
||||||
|
for (const addrs of Object.values(networkInterfaces())) {
|
||||||
|
for (const addr of addrs ?? []) {
|
||||||
|
if (addr.family !== 'IPv4') continue
|
||||||
|
const parts = addr.address.split('.').map(Number)
|
||||||
|
if (parts[0] === 100 && parts[1] !== undefined && parts[1] >= 64 && parts[1] <= 127) {
|
||||||
|
return addr.address
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function meshStatusPayload() {
|
||||||
|
const required = getConfig('mesh.required') === 'true'
|
||||||
|
const meshIntegrationId = getConfig('mesh.integrationId')
|
||||||
|
const verifiedAt = getConfig('mesh.verifiedAt')
|
||||||
|
const overridden = getConfig('mesh.overrideUntil') === 'permanent'
|
||||||
|
return {
|
||||||
|
required,
|
||||||
|
verified: !!verifiedAt,
|
||||||
|
verifiedAt: verifiedAt ?? null,
|
||||||
|
overridden,
|
||||||
|
meshIntegrationId: meshIntegrationId ? Number(meshIntegrationId) : null,
|
||||||
|
hostMeshIp: detectHostMeshIp(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const verifySchema = z.object({ integrationId: z.number().int() })
|
||||||
|
|
||||||
|
export async function systemRoutes(app: FastifyInstance) {
|
||||||
|
app.get('/api/system/mesh-status', { onRequest: [app.authenticate] }, async () => {
|
||||||
|
return meshStatusPayload()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/system/mesh/verify', { onRequest: [app.requireAdmin] }, async (req, reply) => {
|
||||||
|
const parsed = verifySchema.safeParse(req.body)
|
||||||
|
if (!parsed.success) {
|
||||||
|
return reply.code(400).send({ error: parsed.error.issues[0]?.message ?? 'Invalid input' })
|
||||||
|
}
|
||||||
|
const row = db.prepare('SELECT * FROM integrations WHERE id = ? AND type = ?').get(
|
||||||
|
parsed.data.integrationId,
|
||||||
|
'netbird'
|
||||||
|
) as IntegrationRow | undefined
|
||||||
|
if (!row) return reply.code(404).send({ error: 'NetBird integration not found' })
|
||||||
|
|
||||||
|
const adapter = adapterRegistry[row.type as IntegrationType]
|
||||||
|
const config = JSON.parse(row.config_json)
|
||||||
|
const secrets = loadSecrets(row.id)
|
||||||
|
const result = await adapter.testConnection(config, secrets)
|
||||||
|
|
||||||
|
db.prepare("UPDATE integrations SET status = ?, last_checked_at = datetime('now') WHERE id = ?").run(
|
||||||
|
result.ok ? 'connected' : 'error',
|
||||||
|
row.id
|
||||||
|
)
|
||||||
|
|
||||||
|
if (result.ok) {
|
||||||
|
setConfig('mesh.integrationId', String(row.id))
|
||||||
|
setConfig('mesh.verifiedAt', new Date().toISOString())
|
||||||
|
logEvent('mesh_verified', `Mesh verified via ${row.name}`, row.type)
|
||||||
|
} else {
|
||||||
|
logEvent('mesh_verify_failed', `Mesh verification failed via ${row.name}: ${result.message}`, row.type)
|
||||||
|
}
|
||||||
|
|
||||||
|
return { ...result, hostMeshIp: detectHostMeshIp() }
|
||||||
|
})
|
||||||
|
|
||||||
|
app.post('/api/system/mesh/override', { onRequest: [app.requireAdmin] }, async (req) => {
|
||||||
|
setConfig('mesh.overrideUntil', 'permanent')
|
||||||
|
logEvent('mesh_override', `Mesh gate skipped by admin (${(req.user as { username: string }).username})`)
|
||||||
|
return meshStatusPayload()
|
||||||
|
})
|
||||||
|
|
||||||
|
app.put('/api/system/mesh/required', { onRequest: [app.requireAdmin] }, async (req) => {
|
||||||
|
const { required } = req.body as { required: boolean }
|
||||||
|
setConfig('mesh.required', required ? 'true' : 'false')
|
||||||
|
logEvent('mesh_required_changed', `Mesh requirement set to ${required ? 'on' : 'off'}`)
|
||||||
|
return meshStatusPayload()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -18,6 +18,7 @@ import { guacamoleRoutes } from './routes/guacamole.js'
|
||||||
import { metricsRoutes } from './routes/metrics.js'
|
import { metricsRoutes } from './routes/metrics.js'
|
||||||
import { transferRoutes } from './routes/transfer.js'
|
import { transferRoutes } from './routes/transfer.js'
|
||||||
import { dataRoutes } from './routes/data.js'
|
import { dataRoutes } from './routes/data.js'
|
||||||
|
import { systemRoutes } from './routes/system.js'
|
||||||
import { startAutoStartTunnels } from './tunnels/manager.js'
|
import { startAutoStartTunnels } from './tunnels/manager.js'
|
||||||
import { db } from './db/index.js'
|
import { db } from './db/index.js'
|
||||||
|
|
||||||
|
|
@ -99,6 +100,7 @@ await app.register(guacamoleRoutes)
|
||||||
await app.register(metricsRoutes)
|
await app.register(metricsRoutes)
|
||||||
await app.register(transferRoutes)
|
await app.register(transferRoutes)
|
||||||
await app.register(dataRoutes)
|
await app.register(dataRoutes)
|
||||||
|
await app.register(systemRoutes)
|
||||||
|
|
||||||
app.get('/api/health', async () => ({ ok: true }))
|
app.get('/api/health', async () => ({ ok: true }))
|
||||||
|
|
||||||
|
|
|
||||||
20
src/App.tsx
20
src/App.tsx
|
|
@ -15,6 +15,7 @@ import Settings from './pages/Settings'
|
||||||
import Help from './pages/Help'
|
import Help from './pages/Help'
|
||||||
import Login from './pages/Login'
|
import Login from './pages/Login'
|
||||||
import Enrollment from './pages/Enrollment'
|
import Enrollment from './pages/Enrollment'
|
||||||
|
import MeshGate from './pages/MeshGate'
|
||||||
import { useAuth } from './lib/AuthContext'
|
import { useAuth } from './lib/AuthContext'
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
|
|
@ -29,11 +30,14 @@ function App() {
|
||||||
}
|
}
|
||||||
if (status === 'needs-setup' || status === 'enrolling') return <Enrollment />
|
if (status === 'needs-setup' || status === 'enrolling') return <Enrollment />
|
||||||
if (status === 'logged-out') return <Login />
|
if (status === 'logged-out') return <Login />
|
||||||
|
if (status === 'needs-mesh') return <MeshGate />
|
||||||
|
|
||||||
return <Dashboard />
|
return <Dashboard />
|
||||||
}
|
}
|
||||||
|
|
||||||
function Dashboard() {
|
function Dashboard() {
|
||||||
|
const { user, meshStatus } = useAuth()
|
||||||
|
const showMeshNotice = !!meshStatus && meshStatus.required && !meshStatus.verified && !meshStatus.overridden && user?.role !== 'admin'
|
||||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
const sidebarWidth = sidebarCollapsed ? 64 : 200
|
const sidebarWidth = sidebarCollapsed ? 64 : 200
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
@ -79,6 +83,22 @@ function Dashboard() {
|
||||||
<TopBar />
|
<TopBar />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showMeshNotice && (
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-center"
|
||||||
|
style={{
|
||||||
|
zIndex: 20,
|
||||||
|
padding: '6px 16px',
|
||||||
|
backgroundColor: 'rgba(230,126,34,0.1)',
|
||||||
|
borderBottom: '1px solid rgba(230,126,34,0.2)',
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#E67E22',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mesh setup is still in progress — an admin needs to finish verifying the network. Some features may be limited.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
<section
|
<section
|
||||||
className="relative flex w-full flex-col overflow-hidden"
|
className="relative flex w-full flex-col overflow-hidden"
|
||||||
style={{ height: `calc(100vh - ${topBarHeight}px)`, scrollbarWidth: 'none', padding: showHero ? `${heroPaddingTop} 24px 24px 24px` : '16px 24px 24px 24px', gap: '20px', zIndex: 1 }}
|
style={{ height: `calc(100vh - ${topBarHeight}px)`, scrollbarWidth: 'none', padding: showHero ? `${heroPaddingTop} 24px 24px 24px` : '16px 24px 24px 24px', gap: '20px', zIndex: 1 }}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,16 @@
|
||||||
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
import { createContext, useContext, useEffect, useState, type ReactNode } from 'react'
|
||||||
import { api, getToken, setToken, type AuthUser } from './api'
|
import { api, getToken, setToken, type AuthUser, type MeshStatus } from './api'
|
||||||
|
|
||||||
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in'
|
type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'needs-mesh' | 'logged-in'
|
||||||
|
|
||||||
interface AuthContextValue {
|
interface AuthContextValue {
|
||||||
status: AuthStatus
|
status: AuthStatus
|
||||||
user: AuthUser | null
|
user: AuthUser | null
|
||||||
|
meshStatus: MeshStatus | null
|
||||||
login: (username: string, password: string) => Promise<void>
|
login: (username: string, password: string) => Promise<void>
|
||||||
completeSetup: (username: string, password: string) => Promise<void>
|
completeSetup: (username: string, password: string) => Promise<void>
|
||||||
finishEnrollment: () => Promise<void>
|
finishEnrollment: () => Promise<void>
|
||||||
|
refreshMeshStatus: () => Promise<void>
|
||||||
logout: () => void
|
logout: () => void
|
||||||
setUser: (user: AuthUser) => void
|
setUser: (user: AuthUser) => void
|
||||||
}
|
}
|
||||||
|
|
@ -18,13 +20,19 @@ const AuthContext = createContext<AuthContextValue | null>(null)
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [status, setStatus] = useState<AuthStatus>('loading')
|
const [status, setStatus] = useState<AuthStatus>('loading')
|
||||||
const [user, setUser] = useState<AuthUser | null>(null)
|
const [user, setUser] = useState<AuthUser | null>(null)
|
||||||
|
const [meshStatus, setMeshStatus] = useState<MeshStatus | null>(null)
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
try {
|
try {
|
||||||
const { user } = await api.me()
|
const { user } = await api.me()
|
||||||
setUser(user)
|
setUser(user)
|
||||||
setStatus('logged-in')
|
const mesh = await api.getMeshStatus().catch(() => null)
|
||||||
|
setMeshStatus(mesh)
|
||||||
|
// Members are let in with a "setup in progress" notice instead of being gated —
|
||||||
|
// only an admin can actually fix mesh config, so only admins see the full gate.
|
||||||
|
const gateBlocks = !!mesh && mesh.required && !mesh.verified && !mesh.overridden
|
||||||
|
setStatus(gateBlocks && user.role === 'admin' ? 'needs-mesh' : 'logged-in')
|
||||||
return
|
return
|
||||||
} catch {
|
} catch {
|
||||||
setToken(null)
|
setToken(null)
|
||||||
|
|
@ -34,6 +42,10 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
setStatus(needsSetup ? 'needs-setup' : 'logged-out')
|
setStatus(needsSetup ? 'needs-setup' : 'logged-out')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function refreshMeshStatus() {
|
||||||
|
await refresh()
|
||||||
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
refresh()
|
refresh()
|
||||||
}, [])
|
}, [])
|
||||||
|
|
@ -61,11 +73,14 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
api.logout().catch(() => {})
|
api.logout().catch(() => {})
|
||||||
setToken(null)
|
setToken(null)
|
||||||
setUser(null)
|
setUser(null)
|
||||||
|
setMeshStatus(null)
|
||||||
setStatus('logged-out')
|
setStatus('logged-out')
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AuthContext.Provider value={{ status, user, login, completeSetup, finishEnrollment, logout, setUser }}>
|
<AuthContext.Provider
|
||||||
|
value={{ status, user, meshStatus, login, completeSetup, finishEnrollment, refreshMeshStatus, logout, setUser }}
|
||||||
|
>
|
||||||
{children}
|
{children}
|
||||||
</AuthContext.Provider>
|
</AuthContext.Provider>
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,16 @@ export const api = {
|
||||||
logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
|
logout: () => apiFetch<{ ok: boolean }>('/auth/logout', { method: 'POST' }),
|
||||||
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
listLoginEvents: (limit = 20) => apiFetch<{ events: LoginEvent[] }>(`/auth/login-events?limit=${limit}`),
|
||||||
|
|
||||||
|
getMeshStatus: () => apiFetch<MeshStatus>('/system/mesh-status'),
|
||||||
|
verifyMesh: (integrationId: number) =>
|
||||||
|
apiFetch<{ ok: boolean; message: string; hostMeshIp: string | null }>('/system/mesh/verify', {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ integrationId }),
|
||||||
|
}),
|
||||||
|
overrideMesh: () => apiFetch<MeshStatus>('/system/mesh/override', { method: 'POST' }),
|
||||||
|
setMeshRequired: (required: boolean) =>
|
||||||
|
apiFetch<MeshStatus>('/system/mesh/required', { method: 'PUT', body: JSON.stringify({ required }) }),
|
||||||
|
|
||||||
listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'),
|
listUsers: () => apiFetch<{ users: ManagedUser[] }>('/users'),
|
||||||
createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) =>
|
createUser: (data: { username: string; password: string; role: 'admin' | 'member'; displayName?: string | null; email?: string | null }) =>
|
||||||
apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }),
|
apiFetch<{ user: ManagedUser }>('/users', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
@ -237,6 +247,15 @@ export interface AuthSession {
|
||||||
current: boolean
|
current: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface MeshStatus {
|
||||||
|
required: boolean
|
||||||
|
verified: boolean
|
||||||
|
verifiedAt: string | null
|
||||||
|
overridden: boolean
|
||||||
|
meshIntegrationId: number | null
|
||||||
|
hostMeshIp: string | null
|
||||||
|
}
|
||||||
|
|
||||||
export interface LoginEvent {
|
export interface LoginEvent {
|
||||||
id: number
|
id: number
|
||||||
username: string | null
|
username: string | null
|
||||||
|
|
|
||||||
166
src/pages/MeshGate.tsx
Normal file
166
src/pages/MeshGate.tsx
Normal file
|
|
@ -0,0 +1,166 @@
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { Network, ShieldAlert } from 'lucide-react'
|
||||||
|
import { useAuth } from '../lib/AuthContext'
|
||||||
|
import { api, ApiError } from '../lib/api'
|
||||||
|
|
||||||
|
const cardBase: React.CSSProperties = {
|
||||||
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
||||||
|
border: '1px solid rgba(200, 164, 52, 0.12)',
|
||||||
|
borderRadius: '14px',
|
||||||
|
padding: '32px',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldLabel: React.CSSProperties = {
|
||||||
|
fontSize: '11px',
|
||||||
|
color: '#7A7D85',
|
||||||
|
marginBottom: '6px',
|
||||||
|
display: 'block',
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldInput: React.CSSProperties = {
|
||||||
|
width: '100%',
|
||||||
|
height: '36px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: '1px solid rgba(200,164,52,0.12)',
|
||||||
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
||||||
|
color: '#E8E6E0',
|
||||||
|
fontSize: '13px',
|
||||||
|
padding: '0 12px',
|
||||||
|
outline: 'none',
|
||||||
|
}
|
||||||
|
|
||||||
|
const goldButton: React.CSSProperties = {
|
||||||
|
height: '38px',
|
||||||
|
borderRadius: '8px',
|
||||||
|
border: 'none',
|
||||||
|
fontSize: '13px',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: '#0A0B0D',
|
||||||
|
backgroundColor: '#C8A434',
|
||||||
|
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
||||||
|
padding: '0 20px',
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MeshGate() {
|
||||||
|
const { refreshMeshStatus } = useAuth()
|
||||||
|
const [baseUrl, setBaseUrl] = useState('')
|
||||||
|
const [apiKey, setApiKey] = useState('')
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [testResult, setTestResult] = useState<{ ok: boolean; message: string; hostMeshIp: string | null } | null>(null)
|
||||||
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [overriding, setOverriding] = useState(false)
|
||||||
|
|
||||||
|
async function handleVerify(e: React.FormEvent) {
|
||||||
|
e.preventDefault()
|
||||||
|
setError(null)
|
||||||
|
setTestResult(null)
|
||||||
|
setSubmitting(true)
|
||||||
|
try {
|
||||||
|
const { integration } = await api.createIntegration({
|
||||||
|
type: 'netbird',
|
||||||
|
name: 'NetBird',
|
||||||
|
config: baseUrl ? { baseUrl } : {},
|
||||||
|
secrets: apiKey ? { apiKey } : {},
|
||||||
|
})
|
||||||
|
const result = await api.verifyMesh(integration.id)
|
||||||
|
setTestResult(result)
|
||||||
|
if (result.ok) await refreshMeshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Failed to verify mesh')
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleOverride() {
|
||||||
|
setOverriding(true)
|
||||||
|
try {
|
||||||
|
await api.overrideMesh()
|
||||||
|
await refreshMeshStatus()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err instanceof ApiError ? err.message : 'Failed to skip mesh setup')
|
||||||
|
} finally {
|
||||||
|
setOverriding(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
||||||
|
<div style={{ width: '420px' }}>
|
||||||
|
<form onSubmit={handleVerify} style={cardBase}>
|
||||||
|
<div className="flex items-center gap-2" style={{ marginBottom: '4px' }}>
|
||||||
|
<Network size={18} color="#C8A434" />
|
||||||
|
<h1
|
||||||
|
style={{
|
||||||
|
fontSize: '18px',
|
||||||
|
fontWeight: 700,
|
||||||
|
letterSpacing: '1px',
|
||||||
|
textTransform: 'uppercase',
|
||||||
|
color: '#C8A434',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Mesh Setup Required
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
||||||
|
ArchNest expects a verified NetBird mesh before the rest of the app can be configured.
|
||||||
|
Connect your mesh below to continue.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<label style={fieldLabel}>NetBird Base URL</label>
|
||||||
|
<input
|
||||||
|
style={fieldInput}
|
||||||
|
value={baseUrl}
|
||||||
|
onChange={(e) => setBaseUrl(e.target.value)}
|
||||||
|
placeholder="https://api.netbird.io"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<label style={{ ...fieldLabel, marginTop: '14px' }}>API Key</label>
|
||||||
|
<input style={fieldInput} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} required />
|
||||||
|
|
||||||
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
||||||
|
{testResult && (
|
||||||
|
<p style={{ fontSize: '12px', color: testResult.ok ? '#2ECC71' : '#E74C3C', marginTop: '12px' }}>
|
||||||
|
{testResult.message}
|
||||||
|
{testResult.hostMeshIp && ` — this host's mesh IP: ${testResult.hostMeshIp}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
|
||||||
|
{submitting ? 'Verifying…' : 'Connect & Verify'}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div
|
||||||
|
className="flex items-start gap-2"
|
||||||
|
style={{ marginTop: '20px', paddingTop: '16px', borderTop: '1px solid rgba(255,255,255,0.06)' }}
|
||||||
|
>
|
||||||
|
<ShieldAlert size={14} color="#7A7D85" style={{ flexShrink: 0, marginTop: '1px' }} />
|
||||||
|
<div>
|
||||||
|
<p style={{ fontSize: '11px', color: '#7A7D85', marginBottom: '8px' }}>
|
||||||
|
Mesh provider down, or setting this up later? You can skip this check for now — it stays
|
||||||
|
skipped until you re-enable it from Settings.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
type="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: '6px 12px',
|
||||||
|
opacity: overriding ? 0.6 : 1,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{overriding ? 'Skipping…' : 'Skip for now'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue