Uptime Kuma has no REST API for monitor data; connect over the same Socket.IO session the web UI uses (login, then read monitorList and heartbeat events) so connected monitors now surface as Resources. Switches the integration's credentials from an API key to username/password, matching what Uptime Kuma's session login expects.
2115 lines
81 KiB
TypeScript
2115 lines
81 KiB
TypeScript
import { useEffect, useRef, useState } from 'react'
|
|
import { useSearchParams } from 'react-router-dom'
|
|
import { api, ApiError, type Integration, type AuthSession, type LoginEvent, type ManagedUser, type MeshStatus } from '../lib/api'
|
|
import { useAuth } from '../lib/AuthContext'
|
|
import {
|
|
User,
|
|
Palette,
|
|
Plug,
|
|
Bell,
|
|
Database,
|
|
Info,
|
|
Eye,
|
|
EyeOff,
|
|
Check,
|
|
Download,
|
|
Upload,
|
|
Trash2,
|
|
Camera,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Shield,
|
|
Monitor,
|
|
LogOut,
|
|
Users,
|
|
UserPlus,
|
|
Network,
|
|
} from 'lucide-react'
|
|
|
|
const navSections = [
|
|
{ id: 'profile', label: 'Profile', icon: User },
|
|
{ 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 },
|
|
{ id: 'about', label: 'About', icon: Info },
|
|
]
|
|
|
|
const accentColors = [
|
|
{ name: 'Gold', color: '#C8A434' },
|
|
{ name: 'Teal', color: '#2DD4BF' },
|
|
{ name: 'Purple', color: '#A855F7' },
|
|
{ name: 'Blue', color: '#3B82F6' },
|
|
{ name: 'Green', color: '#2ECC71' },
|
|
{ name: 'Red', color: '#E74C3C' },
|
|
]
|
|
|
|
type FieldDef = { key: string; label: string; secret?: boolean; hint?: string; placeholder?: string; file?: boolean }
|
|
|
|
const integrationTypeDefs: { type: string; name: string; multiInstance?: boolean; fields: FieldDef[] }[] = [
|
|
{ type: 'proxmox', name: 'Proxmox', multiInstance: true, fields: [
|
|
{ key: 'baseUrl', label: 'Host URL', hint: 'e.g. https://192.168.1.10:8006', placeholder: 'https://192.168.1.10:8006' },
|
|
{
|
|
key: 'apiKey',
|
|
label: 'API Token',
|
|
secret: true,
|
|
hint: 'Must be the FULL token string from Datacenter → Permissions → API Tokens, in the form USER@REALM!TOKENID=SECRET — not just the secret.',
|
|
placeholder: 'root@pam!archnest=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx',
|
|
},
|
|
] },
|
|
{ type: 'docker', name: 'Docker', multiInstance: true, fields: [
|
|
{ key: 'baseUrl', label: 'Socket / Remote URL', hint: 'Unix socket path or remote daemon URL, e.g. unix:///var/run/docker.sock or tcp://host:2375', placeholder: 'unix:///var/run/docker.sock' },
|
|
] },
|
|
{ type: 'netbird', name: 'NetBird', fields: [
|
|
{ key: 'apiKey', label: 'API Key', secret: true, hint: 'Personal access token from NetBird dashboard → Settings → Access Tokens.' },
|
|
] },
|
|
{ type: 'cloudflare', name: 'Cloudflare', fields: [
|
|
{ key: 'apiKey', label: 'API Token', secret: true, hint: 'A scoped API token (not your Global API Key) from My Profile → API Tokens.' },
|
|
{ key: 'zoneId', label: 'Zone ID', hint: 'Found on the domain overview page in the Cloudflare dashboard.' },
|
|
] },
|
|
{ type: 'aws', name: 'AWS', multiInstance: true, fields: [
|
|
{ key: 'accessKey', label: 'Access Key ID', hint: 'IAM user access key, e.g. AKIAIOSFODNN7EXAMPLE' },
|
|
{ key: 'secretKey', label: 'Secret Access Key', secret: true, hint: 'IAM user secret key — paired with the Access Key ID above.' },
|
|
{ key: 'region', label: 'Region', hint: 'e.g. us-east-1', placeholder: 'us-east-1' },
|
|
] },
|
|
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [
|
|
{ key: 'baseUrl', label: 'URL', placeholder: 'https://uptime.example.com' },
|
|
{ key: 'username', label: 'Username', secret: true },
|
|
{ key: 'password', label: 'Password', secret: true },
|
|
] },
|
|
{ type: 'weather', name: 'Weather API', fields: [{ key: 'location', label: 'Location' }, { key: 'units', label: 'Units' }] },
|
|
{ type: 'remote_desktop', name: 'Remote Desktop', multiInstance: true, fields: [
|
|
{ key: 'protocol', label: 'Protocol (rdp / vnc / telnet)' },
|
|
{ key: 'hostname', label: 'Hostname' },
|
|
{ key: 'port', label: 'Port' },
|
|
{ key: 'username', label: 'Username' },
|
|
{ key: 'domain', label: 'Domain (RDP only)' },
|
|
{ key: 'password', label: 'Password', secret: true },
|
|
] },
|
|
]
|
|
|
|
const sshFields: FieldDef[] = [
|
|
{ key: 'host', label: 'Host / IP' },
|
|
{ key: 'port', label: 'Port (default 22)' },
|
|
{ key: 'username', label: 'Username' },
|
|
{ key: 'password', label: 'Password', secret: true },
|
|
{ key: 'privateKey', label: 'Private Key (PEM)', secret: true, file: true },
|
|
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
|
|
{ key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: true, file: true },
|
|
]
|
|
|
|
const cardBase: React.CSSProperties = {
|
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
|
border: '1px solid rgba(200, 164, 52, 0.08)',
|
|
borderRadius: '12px',
|
|
padding: '22px',
|
|
position: 'relative',
|
|
}
|
|
|
|
const sectionTitle: React.CSSProperties = {
|
|
fontSize: '11px',
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '1.5px',
|
|
color: '#7A7D85',
|
|
fontWeight: 500,
|
|
marginBottom: '16px',
|
|
}
|
|
|
|
const labelStyle: React.CSSProperties = {
|
|
fontSize: '11px',
|
|
color: '#7A7D85',
|
|
marginBottom: '6px',
|
|
display: 'block',
|
|
}
|
|
|
|
const inputStyle: React.CSSProperties = {
|
|
width: '100%',
|
|
height: '34px',
|
|
borderRadius: '8px',
|
|
border: '1px solid rgba(200,164,52,0.12)',
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
color: '#E8E6E0',
|
|
fontSize: '12px',
|
|
padding: '0 12px',
|
|
outline: 'none',
|
|
}
|
|
|
|
function Toggle({ on, onClick }: { on: boolean; onClick: () => void }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
className="cursor-pointer border-none"
|
|
style={{
|
|
width: '38px',
|
|
height: '20px',
|
|
borderRadius: '10px',
|
|
backgroundColor: on ? '#C8A434' : 'rgba(255,255,255,0.08)',
|
|
position: 'relative',
|
|
transition: 'background-color 0.2s ease',
|
|
flexShrink: 0,
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
position: 'absolute',
|
|
top: '2px',
|
|
left: on ? '20px' : '2px',
|
|
width: '16px',
|
|
height: '16px',
|
|
borderRadius: '50%',
|
|
backgroundColor: '#0A0B0D',
|
|
transition: 'left 0.2s ease',
|
|
}}
|
|
/>
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function GoldButton({ children, danger, onClick, disabled }: { children: React.ReactNode; danger?: boolean; onClick?: () => void; disabled?: boolean }) {
|
|
return (
|
|
<button
|
|
onClick={onClick}
|
|
disabled={disabled}
|
|
className="flex items-center gap-2 cursor-pointer transition-colors whitespace-nowrap"
|
|
style={{
|
|
fontSize: '12px',
|
|
fontWeight: 600,
|
|
color: danger ? '#E74C3C' : '#0A0B0D',
|
|
backgroundColor: danger ? 'transparent' : '#C8A434',
|
|
border: danger ? '1px solid rgba(231,76,60,0.4)' : 'none',
|
|
borderRadius: '8px',
|
|
padding: '9px 16px',
|
|
boxShadow: danger ? 'none' : '0 0 14px rgba(200,164,52,0.2)',
|
|
opacity: disabled ? 0.6 : 1,
|
|
}}
|
|
>
|
|
{children}
|
|
</button>
|
|
)
|
|
}
|
|
|
|
function ProfileSection() {
|
|
const { user, setUser } = useAuth()
|
|
const fileInputRef = useRef<HTMLInputElement>(null)
|
|
const [displayName, setDisplayName] = useState(user?.display_name ?? '')
|
|
const [email, setEmail] = useState(user?.email ?? '')
|
|
const [avatar, setAvatar] = useState<string | null>(user?.avatar_data_url ?? null)
|
|
const [saving, setSaving] = useState(false)
|
|
const [savedMsg, setSavedMsg] = useState('')
|
|
|
|
useEffect(() => {
|
|
setDisplayName(user?.display_name ?? '')
|
|
setEmail(user?.email ?? '')
|
|
setAvatar(user?.avatar_data_url ?? null)
|
|
}, [user])
|
|
|
|
const initials = (displayName || user?.username || '?').slice(0, 2).toUpperCase()
|
|
|
|
function handleAvatarChange(e: React.ChangeEvent<HTMLInputElement>) {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
const reader = new FileReader()
|
|
reader.onload = () => setAvatar(reader.result as string)
|
|
reader.readAsDataURL(file)
|
|
}
|
|
|
|
async function handleSave() {
|
|
setSaving(true)
|
|
setSavedMsg('')
|
|
try {
|
|
const { user: updated } = await api.updateMe({ displayName, email, avatarDataUrl: avatar })
|
|
setUser(updated)
|
|
setSavedMsg('Saved')
|
|
} catch (err) {
|
|
setSavedMsg(err instanceof ApiError ? err.message : 'Failed to save')
|
|
} finally {
|
|
setSaving(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Profile</h3>
|
|
<div className="flex items-center gap-4" style={{ marginBottom: '24px' }}>
|
|
<div
|
|
onClick={() => fileInputRef.current?.click()}
|
|
className="relative rounded-full border-2 flex items-center justify-center font-bold cursor-pointer group"
|
|
style={{
|
|
width: '64px',
|
|
height: '64px',
|
|
borderColor: '#C8A434',
|
|
color: '#C8A434',
|
|
fontSize: '20px',
|
|
backgroundColor: 'rgba(200,164,52,0.08)',
|
|
backgroundImage: avatar ? `url(${avatar})` : undefined,
|
|
backgroundSize: 'cover',
|
|
backgroundPosition: 'center',
|
|
overflow: 'hidden',
|
|
}}
|
|
title="Upload photo"
|
|
>
|
|
{!avatar && initials}
|
|
<div
|
|
className="absolute inset-0 flex items-center justify-center opacity-0 group-hover:opacity-100 transition-opacity"
|
|
style={{ backgroundColor: 'rgba(0,0,0,0.55)' }}
|
|
>
|
|
<Camera size={18} color="#E8E6E0" />
|
|
</div>
|
|
</div>
|
|
<input ref={fileInputRef} type="file" accept="image/*" onChange={handleAvatarChange} className="hidden" />
|
|
<div>
|
|
<span style={{ fontSize: '15px', color: '#E8E6E0', fontWeight: 600 }}>{displayName || user?.username}</span>
|
|
<br />
|
|
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{email || 'No email set'}</span>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-4" style={{ marginBottom: '20px' }}>
|
|
<div>
|
|
<label style={labelStyle}>Display Name</label>
|
|
<input style={inputStyle} value={displayName} onChange={(e) => setDisplayName(e.target.value)} />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Email</label>
|
|
<input style={inputStyle} value={email} onChange={(e) => setEmail(e.target.value)} />
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<GoldButton onClick={handleSave} disabled={saving}>
|
|
<Check size={14} />
|
|
{saving ? 'Saving…' : 'Save Changes'}
|
|
</GoldButton>
|
|
{savedMsg && <span style={{ fontSize: '12px', color: '#7A7D85' }}>{savedMsg}</span>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AppearanceSection() {
|
|
const [theme, setTheme] = useState<'dark' | 'light'>('dark')
|
|
const [accent, setAccent] = useState('Gold')
|
|
const [fontSize, setFontSize] = useState(13)
|
|
const [radius, setRadius] = useState(12)
|
|
const [sidebarExpanded, setSidebarExpanded] = useState(true)
|
|
const [animations, setAnimations] = useState(true)
|
|
|
|
return (
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Appearance</h3>
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Theme</span>
|
|
<div className="flex items-center gap-1" style={{ backgroundColor: 'rgba(255,255,255,0.03)', borderRadius: '8px', padding: '3px' }}>
|
|
{(['dark', 'light'] as const).map((t) => (
|
|
<button
|
|
key={t}
|
|
onClick={() => setTheme(t)}
|
|
className="cursor-pointer border-none capitalize"
|
|
style={{
|
|
fontSize: '11px',
|
|
padding: '6px 14px',
|
|
borderRadius: '6px',
|
|
color: theme === t ? '#0A0B0D' : '#7A7D85',
|
|
backgroundColor: theme === t ? '#C8A434' : 'transparent',
|
|
fontWeight: 600,
|
|
}}
|
|
>
|
|
{t}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '10px', display: 'block' }}>Accent Color</span>
|
|
<div className="flex items-center gap-3">
|
|
{accentColors.map((a) => (
|
|
<button
|
|
key={a.name}
|
|
onClick={() => setAccent(a.name)}
|
|
title={a.name}
|
|
className="cursor-pointer border-none rounded-full flex items-center justify-center"
|
|
style={{
|
|
width: '28px',
|
|
height: '28px',
|
|
backgroundColor: a.color,
|
|
outline: accent === a.name ? `2px solid ${a.color}` : 'none',
|
|
outlineOffset: '3px',
|
|
}}
|
|
>
|
|
{accent === a.name && <Check size={14} color="#0A0B0D" />}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Font Size</span>
|
|
<span style={{ fontSize: '11px', color: '#C8A434' }}>{fontSize}px</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={12}
|
|
max={16}
|
|
value={fontSize}
|
|
onChange={(e) => setFontSize(Number(e.target.value))}
|
|
className="w-full"
|
|
style={{ accentColor: '#C8A434' }}
|
|
/>
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Card Border Radius</span>
|
|
<span style={{ fontSize: '11px', color: '#C8A434' }}>{radius}px</span>
|
|
</div>
|
|
<input
|
|
type="range"
|
|
min={4}
|
|
max={16}
|
|
value={radius}
|
|
onChange={(e) => setRadius(Number(e.target.value))}
|
|
className="w-full"
|
|
style={{ accentColor: '#C8A434' }}
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sidebar Expanded by Default</span>
|
|
<Toggle on={sidebarExpanded} onClick={() => setSidebarExpanded((v) => !v)} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between">
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Animations</span>
|
|
<Toggle on={animations} onClick={() => setAnimations((v) => !v)} />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function SshHostsSection() {
|
|
const [hosts, setHosts] = useState<Integration[] | null>(null)
|
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
|
const [drafts, setDrafts] = useState<Record<number, Record<string, string>>>({})
|
|
const [statusMsg, setStatusMsg] = useState<Record<number, string>>({})
|
|
const [busy, setBusy] = useState<Set<number>>(new Set())
|
|
const [collapsed, setCollapsed] = useState<Set<number>>(new Set())
|
|
const [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([])
|
|
const nextNewKey = useRef(-1)
|
|
const fileInputRefs = useRef<Record<string, HTMLInputElement | null>>({})
|
|
const collapseInitialized = useRef(false)
|
|
|
|
function toggleCollapsed(id: number) {
|
|
setCollapsed((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(id)) next.delete(id)
|
|
else next.add(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
useEffect(() => {
|
|
refresh()
|
|
}, [])
|
|
|
|
function refresh() {
|
|
api.listIntegrations().then(({ integrations }) => {
|
|
const sshHosts = integrations.filter((i) => i.type === 'ssh')
|
|
setHosts(sshHosts)
|
|
if (!collapseInitialized.current) {
|
|
collapseInitialized.current = true
|
|
setCollapsed(new Set(sshHosts.filter((h) => h.secretKeys.length > 0).map((h) => h.id)))
|
|
}
|
|
})
|
|
}
|
|
|
|
function toggleReveal(key: string) {
|
|
setRevealed((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(key)) next.delete(key)
|
|
else next.add(key)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function setBusyFlag(id: number, value: boolean) {
|
|
setBusy((prev) => {
|
|
const next = new Set(prev)
|
|
if (value) next.add(id)
|
|
else next.delete(id)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function addNewHost() {
|
|
const key = nextNewKey.current--
|
|
setNewDrafts((prev) => [...prev, { key, values: {} }])
|
|
}
|
|
|
|
function setNewDraftField(key: number, fieldKey: string, value: string) {
|
|
setNewDrafts((prev) => prev.map((d) => (d.key === key ? { ...d, values: { ...d.values, [fieldKey]: value } } : d)))
|
|
}
|
|
|
|
function removeNewDraft(key: number) {
|
|
setNewDrafts((prev) => prev.filter((d) => d.key !== key))
|
|
}
|
|
|
|
function setDraftField(id: number, fieldKey: string, value: string) {
|
|
setDrafts((prev) => ({ ...prev, [id]: { ...prev[id], [fieldKey]: value } }))
|
|
}
|
|
|
|
const fieldsWithJumpHost = (): FieldDef[] => [
|
|
...sshFields,
|
|
{ key: 'jumpHostIntegrationId', label: 'Jump Host (optional)' },
|
|
{ key: 'sessionLogging', label: 'Record session to disk' },
|
|
]
|
|
|
|
function buildPayload(fields: FieldDef[], values: Record<string, string>, existing?: Integration) {
|
|
const config: Record<string, string> = { ...(existing?.config ?? {}) }
|
|
const secrets: Record<string, string> = {}
|
|
for (const f of fields) {
|
|
const value = values[f.key]
|
|
if (value === undefined) continue
|
|
if (f.secret) secrets[f.key] = value
|
|
else config[f.key] = value
|
|
}
|
|
return { config, secrets }
|
|
}
|
|
|
|
async function handleSaveExisting(host: Integration) {
|
|
setBusyFlag(host.id, true)
|
|
setStatusMsg((prev) => ({ ...prev, [host.id]: '' }))
|
|
try {
|
|
const draft = drafts[host.id] ?? {}
|
|
const { config, secrets } = buildPayload(fieldsWithJumpHost(), draft, host)
|
|
const name = draft.__name?.trim()
|
|
const { integration } = await api.updateIntegration(host.id, { ...(name ? { name } : {}), config, secrets })
|
|
setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h)))
|
|
setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' }))
|
|
setCollapsed((prev) => new Set(prev).add(host.id))
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Save failed' }))
|
|
} finally {
|
|
setBusyFlag(host.id, false)
|
|
}
|
|
}
|
|
|
|
async function handleSaveNew(key: number, values: Record<string, string>) {
|
|
setBusyFlag(key, true)
|
|
try {
|
|
const { config, secrets } = buildPayload(fieldsWithJumpHost(), values)
|
|
const name = values.__name?.trim() || (values.host ? `SSH: ${values.host}` : 'SSH Host')
|
|
await api.createIntegration({ type: 'ssh', name, config, secrets })
|
|
removeNewDraft(key)
|
|
refresh()
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [key]: err instanceof ApiError ? err.message : 'Save failed' }))
|
|
} finally {
|
|
setBusyFlag(key, false)
|
|
}
|
|
}
|
|
|
|
async function handleTest(host: Integration) {
|
|
setBusyFlag(host.id, true)
|
|
try {
|
|
const result = await api.testIntegration(host.id)
|
|
setStatusMsg((prev) => ({ ...prev, [host.id]: result.message }))
|
|
refresh()
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Test failed' }))
|
|
} finally {
|
|
setBusyFlag(host.id, false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(host: Integration) {
|
|
setBusyFlag(host.id, true)
|
|
try {
|
|
await api.deleteIntegration(host.id)
|
|
refresh()
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [host.id]: err instanceof ApiError ? err.message : 'Delete failed' }))
|
|
setBusyFlag(host.id, false)
|
|
}
|
|
}
|
|
|
|
function renderFields(
|
|
fields: FieldDef[],
|
|
values: Record<string, string>,
|
|
onChange: (fieldKey: string, value: string) => void,
|
|
idForReveal: number,
|
|
existing: Integration | undefined,
|
|
excludeHostId?: number,
|
|
) {
|
|
return fields.map((f) => {
|
|
const key = `${idForReveal}-${f.key}`
|
|
if (f.key === 'jumpHostIntegrationId') {
|
|
const options = (hosts ?? []).filter((h) => h.id !== excludeHostId)
|
|
const savedValue = existing?.config[f.key] ?? ''
|
|
const value = values[f.key] ?? savedValue
|
|
return (
|
|
<div key={key}>
|
|
<label style={labelStyle}>{f.label}</label>
|
|
<select style={inputStyle} value={value} onChange={(e) => onChange(f.key, e.target.value)}>
|
|
<option value="">None</option>
|
|
{options.map((h) => (
|
|
<option key={h.id} value={String(h.id)}>
|
|
{h.name}
|
|
</option>
|
|
))}
|
|
</select>
|
|
</div>
|
|
)
|
|
}
|
|
if (f.key === 'sessionLogging') {
|
|
const savedValue = existing?.config[f.key] === 'true'
|
|
const value = values[f.key] !== undefined ? values[f.key] === 'true' : savedValue
|
|
return (
|
|
<div key={key} className="flex items-end pb-1.5">
|
|
<label className="flex items-center gap-2 text-xs" style={{ color: '#E8E6E0' }}>
|
|
<input type="checkbox" checked={value} onChange={(e) => onChange(f.key, e.target.checked ? 'true' : 'false')} />
|
|
{f.label}
|
|
</label>
|
|
</div>
|
|
)
|
|
}
|
|
const isRevealed = revealed.has(key)
|
|
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
|
|
const value = values[f.key] ?? savedValue
|
|
|
|
if (f.file) {
|
|
const isSaved = existing?.secretKeys?.includes(f.key)
|
|
return (
|
|
<div key={key} className="col-span-3">
|
|
<label style={labelStyle}>
|
|
{f.label}
|
|
{isSaved && <span style={{ color: '#2ECC71', fontWeight: 400 }}> · saved</span>}
|
|
</label>
|
|
<div className="relative">
|
|
<textarea
|
|
style={{ ...inputStyle, height: '90px', paddingRight: '32px', fontFamily: 'monospace', resize: 'vertical', whiteSpace: 'pre' }}
|
|
value={value}
|
|
onChange={(e) => onChange(f.key, e.target.value)}
|
|
placeholder={isSaved ? '•••••••••••• (saved — paste to replace)' : 'Paste key contents here, or upload a file'}
|
|
/>
|
|
<input
|
|
type="file"
|
|
ref={(el) => { fileInputRefs.current[key] = el }}
|
|
style={{ display: 'none' }}
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (!file) return
|
|
const reader = new FileReader()
|
|
reader.onload = () => onChange(f.key, String(reader.result ?? '').trim())
|
|
reader.readAsText(file)
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
<button
|
|
onClick={() => fileInputRefs.current[key]?.click()}
|
|
title="Upload from file"
|
|
className="absolute cursor-pointer border-none bg-transparent"
|
|
style={{ right: '8px', top: '8px', color: '#7A7D85' }}
|
|
>
|
|
<Upload size={13} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const isSavedSecret = f.secret && existing?.secretKeys?.includes(f.key)
|
|
return (
|
|
<div key={key}>
|
|
<label style={labelStyle}>
|
|
{f.label}
|
|
{isSavedSecret && <span style={{ color: '#2ECC71', fontWeight: 400 }}> · saved</span>}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
style={{ ...inputStyle, paddingRight: f.secret ? '32px' : undefined }}
|
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
|
value={value}
|
|
onChange={(e) => onChange(f.key, e.target.value)}
|
|
placeholder={isSavedSecret ? '•••••••••••• (saved — type to replace)' : 'Not configured'}
|
|
/>
|
|
{f.secret && (
|
|
<button
|
|
onClick={() => toggleReveal(key)}
|
|
className="absolute cursor-pointer border-none bg-transparent"
|
|
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
|
>
|
|
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
}
|
|
|
|
if (!hosts) {
|
|
return (
|
|
<div style={cardBase}>
|
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading SSH hosts…</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{hosts.map((host) => {
|
|
const online = host.status === 'connected'
|
|
const draft = drafts[host.id] ?? {}
|
|
const isCollapsed = collapsed.has(host.id)
|
|
return (
|
|
<div key={host.id} style={cardBase}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: isCollapsed ? 0 : '16px' }}>
|
|
<button
|
|
onClick={() => toggleCollapsed(host.id)}
|
|
className="flex items-center gap-2.5 cursor-pointer border-none bg-transparent"
|
|
style={{ padding: 0 }}
|
|
title={isCollapsed ? 'Expand' : 'Collapse'}
|
|
>
|
|
{isCollapsed ? <ChevronRight size={14} color="#7A7D85" /> : <ChevronDown size={14} color="#7A7D85" />}
|
|
<span
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: online ? '#2ECC71' : '#4A4D55',
|
|
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
|
}}
|
|
/>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{draft.__name ?? host.name}</span>
|
|
</button>
|
|
<div className="flex items-center gap-2">
|
|
{statusMsg[host.id] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[host.id]}</span>}
|
|
{!isCollapsed && (
|
|
<button
|
|
onClick={() => handleSaveExisting(host)}
|
|
disabled={busy.has(host.id)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
|
|
>
|
|
Save
|
|
</button>
|
|
)}
|
|
<button
|
|
onClick={() => handleTest(host)}
|
|
disabled={busy.has(host.id)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
|
|
>
|
|
Test Connection
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(host)}
|
|
disabled={busy.has(host.id)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#E74C3C', backgroundColor: 'transparent', border: '1px solid rgba(231,76,60,0.3)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(host.id) ? 0.6 : 1 }}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{!isCollapsed && (
|
|
<div className="grid grid-cols-3 gap-4" style={{ marginTop: '16px' }}>
|
|
<div>
|
|
<label style={labelStyle}>Host Name</label>
|
|
<input
|
|
style={inputStyle}
|
|
value={draft.__name ?? host.name}
|
|
onChange={(e) => setDraftField(host.id, '__name', e.target.value)}
|
|
placeholder="Not configured"
|
|
/>
|
|
</div>
|
|
{renderFields(fieldsWithJumpHost(), draft, (k, v) => setDraftField(host.id, k, v), host.id, host, host.id)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{newDrafts.map((d) => (
|
|
<div key={d.key} style={cardBase}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>New SSH Host</span>
|
|
<div className="flex items-center gap-2">
|
|
{statusMsg[d.key] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[d.key]}</span>}
|
|
<button
|
|
onClick={() => handleSaveNew(d.key, d.values)}
|
|
disabled={busy.has(d.key)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(d.key) ? 0.6 : 1 }}
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={() => removeNewDraft(d.key)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#7A7D85', backgroundColor: 'transparent', border: '1px solid rgba(255,255,255,0.08)', borderRadius: '6px', padding: '6px 12px' }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4">
|
|
<div>
|
|
<label style={labelStyle}>Host Name</label>
|
|
<input
|
|
style={inputStyle}
|
|
value={d.values.__name ?? ''}
|
|
onChange={(e) => setNewDraftField(d.key, '__name', e.target.value)}
|
|
placeholder="SSH Host"
|
|
/>
|
|
</div>
|
|
{renderFields(fieldsWithJumpHost(), d.values, (k, v) => setNewDraftField(d.key, k, v), d.key, undefined)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
|
|
<button
|
|
onClick={addNewHost}
|
|
className="cursor-pointer border-none self-start"
|
|
style={{ fontSize: '12px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '8px', padding: '9px 16px' }}
|
|
>
|
|
+ Add SSH Host
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type NewIntegrationDraft = { id: number; type: string; values: Record<string, string> }
|
|
|
|
function dockerHostInfo(baseUrl: string): { host: string; port: string } | null {
|
|
if (!baseUrl || baseUrl.startsWith('unix://')) return null
|
|
try {
|
|
const u = new URL(baseUrl.replace(/^tcp:\/\//, 'http://'))
|
|
if (u.protocol !== 'http:' && u.protocol !== 'https:') return null
|
|
if (!u.hostname) return null
|
|
return { host: u.hostname, port: u.port || '2375' }
|
|
} catch {
|
|
return null
|
|
}
|
|
}
|
|
|
|
function DockerSetupHint({ baseUrl }: { baseUrl: string }) {
|
|
const [copied, setCopied] = useState(false)
|
|
const info = dockerHostInfo(baseUrl)
|
|
if (!info) return null
|
|
|
|
const script = `sudo mkdir -p /etc/systemd/system/docker.service.d
|
|
sudo tee /etc/systemd/system/docker.service.d/override.conf > /dev/null <<EOF
|
|
[Service]
|
|
ExecStart=
|
|
ExecStart=/usr/bin/dockerd -H fd:// -H tcp://${info.host}:${info.port}
|
|
EOF
|
|
sudo systemctl daemon-reload
|
|
sudo systemctl restart docker
|
|
curl http://${info.host}:${info.port}/version`
|
|
|
|
return (
|
|
<div
|
|
style={{
|
|
marginTop: '12px',
|
|
padding: '12px 14px',
|
|
borderRadius: '8px',
|
|
border: '1px solid rgba(200,164,52,0.12)',
|
|
backgroundColor: 'rgba(255,255,255,0.02)',
|
|
}}
|
|
>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>
|
|
Run this on <strong style={{ color: '#E8E6E0' }}>{info.host}</strong> to expose its Docker API on port {info.port}:
|
|
</span>
|
|
<button
|
|
onClick={() => {
|
|
navigator.clipboard.writeText(script)
|
|
setCopied(true)
|
|
setTimeout(() => setCopied(false), 1500)
|
|
}}
|
|
className="flex items-center gap-1 cursor-pointer border-none bg-transparent"
|
|
style={{ fontSize: '10.5px', fontWeight: 600, color: copied ? '#2ECC71' : '#C8A434', flexShrink: 0 }}
|
|
>
|
|
{copied ? <Check size={11} /> : null}
|
|
{copied ? 'Copied' : 'Copy script'}
|
|
</button>
|
|
</div>
|
|
<pre
|
|
style={{
|
|
fontSize: '10.5px',
|
|
color: '#9DA0A8',
|
|
fontFamily: 'monospace',
|
|
whiteSpace: 'pre-wrap',
|
|
wordBreak: 'break-all',
|
|
margin: 0,
|
|
lineHeight: 1.5,
|
|
}}
|
|
>
|
|
{script}
|
|
</pre>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function IntegrationsSection() {
|
|
const { user } = useAuth()
|
|
const isAdmin = user?.role === 'admin'
|
|
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
|
|
const [revealed, setRevealed] = useState<Set<string>>(new Set())
|
|
const [editDrafts, setEditDrafts] = useState<Record<number, Record<string, string>>>({})
|
|
const [newDrafts, setNewDrafts] = useState<NewIntegrationDraft[]>([])
|
|
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
|
|
const [busy, setBusy] = useState<Set<string>>(new Set())
|
|
const nextDraftId = useRef(-1)
|
|
|
|
useEffect(() => {
|
|
api.listIntegrations().then(({ integrations }) => setIntegrations(integrations))
|
|
}, [])
|
|
|
|
function toggleReveal(key: string) {
|
|
setRevealed((prev) => {
|
|
const next = new Set(prev)
|
|
if (next.has(key)) next.delete(key)
|
|
else next.add(key)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function setBusyFlag(rowKey: string, value: boolean) {
|
|
setBusy((prev) => {
|
|
const next = new Set(prev)
|
|
if (value) next.add(rowKey)
|
|
else next.delete(rowKey)
|
|
return next
|
|
})
|
|
}
|
|
|
|
function setEditField(integrationId: number, fieldKey: string, value: string) {
|
|
setEditDrafts((prev) => ({ ...prev, [integrationId]: { ...prev[integrationId], [fieldKey]: value } }))
|
|
}
|
|
|
|
function setNewDraftField(draftId: number, fieldKey: string, value: string) {
|
|
setNewDrafts((prev) => prev.map((d) => (d.id === draftId ? { ...d, values: { ...d.values, [fieldKey]: value } } : d)))
|
|
}
|
|
|
|
function addNewDraft(type: string) {
|
|
const id = nextDraftId.current--
|
|
setNewDrafts((prev) => [...prev, { id, type, values: {} }])
|
|
}
|
|
|
|
function removeNewDraft(id: number) {
|
|
setNewDrafts((prev) => prev.filter((d) => d.id !== id))
|
|
}
|
|
|
|
function buildPayload(def: (typeof integrationTypeDefs)[number], values: Record<string, string>, existing?: Integration) {
|
|
const config: Record<string, string> = { ...(existing?.config ?? {}) }
|
|
const secrets: Record<string, string> = {}
|
|
for (const f of def.fields) {
|
|
const value = values[f.key]
|
|
if (value === undefined) continue
|
|
if (f.secret) secrets[f.key] = value
|
|
else config[f.key] = value
|
|
}
|
|
return { config, secrets }
|
|
}
|
|
|
|
async function handleSaveExisting(def: (typeof integrationTypeDefs)[number], existing: Integration) {
|
|
const rowKey = `e-${existing.id}`
|
|
setBusyFlag(rowKey, true)
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: '' }))
|
|
try {
|
|
const { config, secrets } = buildPayload(def, editDrafts[existing.id] ?? {}, existing)
|
|
const name = editDrafts[existing.id]?.__name?.trim()
|
|
const { integration } = await api.updateIntegration(existing.id, { ...(name ? { name } : {}), config, secrets })
|
|
setIntegrations((prev) => (prev ?? []).map((i) => (i.id === integration.id ? integration : i)))
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: 'Saved' }))
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Save failed' }))
|
|
} finally {
|
|
setBusyFlag(rowKey, false)
|
|
}
|
|
}
|
|
|
|
async function handleSaveNew(def: (typeof integrationTypeDefs)[number], draft: NewIntegrationDraft) {
|
|
const rowKey = `n-${draft.id}`
|
|
setBusyFlag(rowKey, true)
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: '' }))
|
|
try {
|
|
const { config, secrets } = buildPayload(def, draft.values)
|
|
const name = draft.values.__name?.trim() || def.name
|
|
const { integration } = await api.createIntegration({ type: def.type, name, config, secrets })
|
|
setIntegrations((prev) => [...(prev ?? []), integration])
|
|
removeNewDraft(draft.id)
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Save failed' }))
|
|
} finally {
|
|
setBusyFlag(rowKey, false)
|
|
}
|
|
}
|
|
|
|
async function handleTest(existing: Integration) {
|
|
const rowKey = `e-${existing.id}`
|
|
setBusyFlag(rowKey, true)
|
|
try {
|
|
const result = await api.testIntegration(existing.id)
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: result.message }))
|
|
const { integrations } = await api.listIntegrations()
|
|
setIntegrations(integrations)
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Test failed' }))
|
|
} finally {
|
|
setBusyFlag(rowKey, false)
|
|
}
|
|
}
|
|
|
|
async function handleDelete(existing: Integration) {
|
|
if (!window.confirm(`Remove this ${existing.name} integration?`)) return
|
|
const rowKey = `e-${existing.id}`
|
|
setBusyFlag(rowKey, true)
|
|
try {
|
|
await api.deleteIntegration(existing.id)
|
|
setIntegrations((prev) => (prev ?? []).filter((i) => i.id !== existing.id))
|
|
} catch (err) {
|
|
setStatusMsg((prev) => ({ ...prev, [rowKey]: err instanceof ApiError ? err.message : 'Delete failed' }))
|
|
setBusyFlag(rowKey, false)
|
|
}
|
|
}
|
|
|
|
if (!integrations) {
|
|
return (
|
|
<div style={cardBase}>
|
|
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading integrations…</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function renderFields(def: (typeof integrationTypeDefs)[number], rowKey: string, getValue: (f: FieldDef) => string, onChange: (f: FieldDef, value: string) => void, placeholderForSecret: string, existing?: Integration) {
|
|
return (
|
|
<div className="grid grid-cols-3 gap-4">
|
|
{def.fields.map((f) => {
|
|
const key = `${rowKey}-${f.key}`
|
|
const isRevealed = revealed.has(key)
|
|
const isSavedSecret = f.secret && existing?.secretKeys?.includes(f.key)
|
|
return (
|
|
<div key={key}>
|
|
<label style={labelStyle}>
|
|
{f.label}
|
|
{isSavedSecret && <span style={{ color: '#2ECC71', fontWeight: 400 }}> · saved</span>}
|
|
</label>
|
|
<div className="relative">
|
|
<input
|
|
style={inputStyle}
|
|
type={f.secret && !isRevealed ? 'password' : 'text'}
|
|
value={getValue(f)}
|
|
onChange={(e) => onChange(f, e.target.value)}
|
|
placeholder={f.secret ? (isSavedSecret ? `${placeholderForSecret} (saved — type to replace)` : placeholderForSecret) : f.placeholder ?? 'Not configured'}
|
|
/>
|
|
{f.secret && (
|
|
<button
|
|
onClick={() => toggleReveal(key)}
|
|
className="absolute cursor-pointer border-none bg-transparent"
|
|
style={{ right: '8px', top: '50%', transform: 'translateY(-50%)', color: '#7A7D85' }}
|
|
>
|
|
{isRevealed ? <EyeOff size={13} /> : <Eye size={13} />}
|
|
</button>
|
|
)}
|
|
</div>
|
|
{f.hint && (
|
|
<p style={{ fontSize: '10.5px', color: '#7A7D85', marginTop: '4px', lineHeight: 1.4, maxWidth: '260px' }}>{f.hint}</p>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{!isAdmin && (
|
|
<div
|
|
style={{
|
|
padding: '12px 14px',
|
|
borderRadius: '10px',
|
|
border: '1px solid rgba(200,164,52,0.2)',
|
|
backgroundColor: 'rgba(200,164,52,0.06)',
|
|
fontSize: '12px',
|
|
color: '#C8A434',
|
|
}}
|
|
>
|
|
You have member access — integrations are read-only. Ask an administrator to add or change connections.
|
|
</div>
|
|
)}
|
|
<div>
|
|
<h3 style={sectionTitle}>SSH Hosts</h3>
|
|
<SshHostsSection />
|
|
</div>
|
|
<div>
|
|
<h3 style={sectionTitle}>Other Integrations</h3>
|
|
</div>
|
|
{integrationTypeDefs.map((def) => {
|
|
const existingRows = integrations.filter((i) => i.type === def.type)
|
|
const draftRows = newDrafts.filter((d) => d.type === def.type)
|
|
const canAddAnother = def.multiInstance || existingRows.length + draftRows.length === 0
|
|
|
|
return (
|
|
<div key={def.type} className="flex flex-col gap-3">
|
|
{existingRows.map((existing) => {
|
|
const rowKey = `e-${existing.id}`
|
|
const online = existing.status === 'connected'
|
|
const draft = editDrafts[existing.id] ?? {}
|
|
return (
|
|
<div key={existing.id} style={cardBase}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<div className="flex items-center gap-2.5">
|
|
<span
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: online ? '#2ECC71' : '#4A4D55',
|
|
boxShadow: online ? '0 0 6px rgba(46,204,113,0.6)' : 'none',
|
|
}}
|
|
/>
|
|
<input
|
|
value={draft.__name ?? existing.name ?? def.name}
|
|
onChange={(e) => setEditField(existing.id, '__name', e.target.value)}
|
|
style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600, backgroundColor: 'transparent', border: 'none', outline: 'none', padding: 0, width: '160px' }}
|
|
/>
|
|
{existing.config.baseUrl || existing.config.hostname ? (
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{existing.config.baseUrl || existing.config.hostname}</span>
|
|
) : null}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{statusMsg[rowKey] && (
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[rowKey]}</span>
|
|
)}
|
|
<button
|
|
onClick={() => handleSaveExisting(def, existing)}
|
|
disabled={busy.has(rowKey)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={() => handleTest(existing)}
|
|
disabled={busy.has(rowKey)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
|
>
|
|
Test Connection
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(existing)}
|
|
disabled={busy.has(rowKey)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#E74C3C', backgroundColor: 'rgba(231,76,60,0.08)', border: '1px solid rgba(231,76,60,0.2)', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
|
>
|
|
Remove
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{renderFields(
|
|
def,
|
|
rowKey,
|
|
(f) => draft[f.key] ?? (f.secret ? '' : existing.config[f.key] ?? ''),
|
|
(f, value) => setEditField(existing.id, f.key, value),
|
|
'••••••••••••',
|
|
existing,
|
|
)}
|
|
{def.type === 'docker' && (
|
|
<DockerSetupHint baseUrl={draft.baseUrl ?? existing.config.baseUrl ?? ''} />
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{draftRows.map((draft) => {
|
|
const rowKey = `n-${draft.id}`
|
|
return (
|
|
<div key={draft.id} style={cardBase}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>New {def.name}</span>
|
|
<div className="flex items-center gap-2">
|
|
{statusMsg[rowKey] && (
|
|
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[rowKey]}</span>
|
|
)}
|
|
<button
|
|
onClick={() => handleSaveNew(def, draft)}
|
|
disabled={busy.has(rowKey)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#0A0B0D', backgroundColor: '#C8A434', borderRadius: '6px', padding: '6px 12px', opacity: busy.has(rowKey) ? 0.6 : 1 }}
|
|
>
|
|
Save
|
|
</button>
|
|
<button
|
|
onClick={() => removeNewDraft(draft.id)}
|
|
className="cursor-pointer border-none"
|
|
style={{ fontSize: '11px', fontWeight: 600, color: '#7A7D85', backgroundColor: 'rgba(255,255,255,0.04)', border: '1px solid rgba(255,255,255,0.1)', borderRadius: '6px', padding: '6px 12px' }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-3 gap-4" style={{ marginBottom: '16px' }}>
|
|
<div>
|
|
<label style={labelStyle}>Name</label>
|
|
<input
|
|
style={inputStyle}
|
|
value={draft.values.__name ?? ''}
|
|
onChange={(e) => setNewDraftField(draft.id, '__name', e.target.value)}
|
|
placeholder={def.name}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{renderFields(
|
|
def,
|
|
rowKey,
|
|
(f) => draft.values[f.key] ?? '',
|
|
(f, value) => setNewDraftField(draft.id, f.key, value),
|
|
'',
|
|
)}
|
|
{def.type === 'docker' && <DockerSetupHint baseUrl={draft.values.baseUrl ?? ''} />}
|
|
</div>
|
|
)
|
|
})}
|
|
|
|
{existingRows.length === 0 && draftRows.length === 0 && (
|
|
<button
|
|
onClick={() => addNewDraft(def.type)}
|
|
className="cursor-pointer self-start"
|
|
style={{ fontSize: '12px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '8px', padding: '9px 16px' }}
|
|
>
|
|
+ Add {def.name}
|
|
</button>
|
|
)}
|
|
{canAddAnother && (existingRows.length > 0 || draftRows.length > 0) && (
|
|
<button
|
|
onClick={() => addNewDraft(def.type)}
|
|
className="cursor-pointer self-start"
|
|
style={{ fontSize: '12px', fontWeight: 600, color: '#C8A434', backgroundColor: 'rgba(200,164,52,0.08)', border: '1px solid rgba(200,164,52,0.2)', borderRadius: '8px', padding: '9px 16px' }}
|
|
>
|
|
+ Add Another {def.name}
|
|
</button>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function NotificationsSection() {
|
|
const [enabled, setEnabled] = useState(true)
|
|
const [email, setEmail] = useState(true)
|
|
const [push, setPush] = useState(false)
|
|
const [sound, setSound] = useState(true)
|
|
|
|
return (
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Notifications</h3>
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '20px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Enable Notifications</span>
|
|
<Toggle on={enabled} onClick={() => setEnabled((v) => !v)} />
|
|
</div>
|
|
|
|
<div style={{ marginBottom: '20px' }}>
|
|
<label style={labelStyle}>Alert Threshold</label>
|
|
<select style={{ ...inputStyle, width: '220px' }} defaultValue="all">
|
|
<option value="all">All</option>
|
|
<option value="critical">Critical Only</option>
|
|
<option value="warning">Warning & Above</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Email Notifications</span>
|
|
<Toggle on={email} onClick={() => setEmail((v) => !v)} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Browser Push</span>
|
|
<Toggle on={push} onClick={() => setPush((v) => !v)} />
|
|
</div>
|
|
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '8px' }}>
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Sound</span>
|
|
<Toggle on={sound} onClick={() => setSound((v) => !v)} />
|
|
</div>
|
|
{sound && (
|
|
<input type="range" min={0} max={100} defaultValue={70} className="w-full" style={{ accentColor: '#C8A434' }} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function DataBackupSection() {
|
|
const { user } = useAuth()
|
|
const [busy, setBusy] = useState(false)
|
|
const [message, setMessage] = useState<string | null>(null)
|
|
const [error, setError] = useState<string | null>(null)
|
|
const importRef = useRef<HTMLInputElement | null>(null)
|
|
|
|
if (user?.role !== 'admin') {
|
|
return (
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Data & Backup</h3>
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>
|
|
Backup export and import are restricted to administrators.
|
|
</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
async function handleExport() {
|
|
setBusy(true)
|
|
setError(null)
|
|
setMessage(null)
|
|
try {
|
|
const data = await api.exportData()
|
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
|
|
const url = URL.createObjectURL(blob)
|
|
const a = document.createElement('a')
|
|
a.href = url
|
|
a.download = `archnest-backup-${new Date().toISOString().slice(0, 10)}.json`
|
|
a.click()
|
|
URL.revokeObjectURL(url)
|
|
setMessage(`Exported ${data.integrations.length} integrations, ${data.bookmarks.length} bookmarks, ${data.tunnels.length} tunnels.`)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Export failed')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
async function handleImportFile(file: File) {
|
|
setBusy(true)
|
|
setError(null)
|
|
setMessage(null)
|
|
try {
|
|
const text = await file.text()
|
|
const parsed = JSON.parse(text)
|
|
const result = await api.importData(parsed)
|
|
const c = result.imported
|
|
setMessage(`Imported ${c.integrations} integrations, ${c.bookmarkCategories} categories, ${c.bookmarks} bookmarks, ${c.tunnels} tunnels.`)
|
|
} catch (err) {
|
|
setError(err instanceof Error ? err.message : 'Import failed — is this a valid ArchNest backup file?')
|
|
} finally {
|
|
setBusy(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Data & Backup</h3>
|
|
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '16px', maxWidth: '460px' }}>
|
|
Export a portable backup of all integrations (including their credentials), bookmarks, and tunnels as a single JSON file, or
|
|
import one into this instance. Imports are additive — existing data is kept and the backup's items are added alongside it.
|
|
The backup contains plaintext credentials, so store it securely.
|
|
</p>
|
|
<div className="flex flex-col gap-3" style={{ maxWidth: '460px' }}>
|
|
<div className="flex items-center justify-between">
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Export all data (JSON)</span>
|
|
<GoldButton onClick={handleExport} disabled={busy}>
|
|
<Download size={13} /> Export
|
|
</GoldButton>
|
|
</div>
|
|
<div className="flex items-center justify-between">
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>Import from backup (JSON)</span>
|
|
<GoldButton onClick={() => importRef.current?.click()} disabled={busy}>
|
|
<Upload size={13} /> Import
|
|
</GoldButton>
|
|
<input
|
|
ref={importRef}
|
|
type="file"
|
|
accept="application/json,.json"
|
|
className="hidden"
|
|
onChange={(e) => {
|
|
const file = e.target.files?.[0]
|
|
if (file) handleImportFile(file)
|
|
e.target.value = ''
|
|
}}
|
|
/>
|
|
</div>
|
|
{message && <p style={{ fontSize: '12px', color: '#2ECC71' }}>{message}</p>}
|
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C' }}>{error}</p>}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AboutSection() {
|
|
const rows: [string, string][] = [
|
|
['App', 'ArchNest Dashboard v1.0.0'],
|
|
['Author', 'Samuel James'],
|
|
['Repo', 'github.com/SamuelSJames/archnest'],
|
|
['Stack', 'React 19, Vite, TypeScript'],
|
|
['License', 'MIT'],
|
|
]
|
|
return (
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>About</h3>
|
|
<div className="flex flex-col gap-3">
|
|
{rows.map(([label, value]) => (
|
|
<div key={label} className="flex items-center justify-between">
|
|
<span style={{ fontSize: '12px', color: '#7A7D85' }}>{label}</span>
|
|
<span style={{ fontSize: '12px', color: '#E8E6E0' }}>{value}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function relativeTime(iso: string): string {
|
|
// SQLite datetime('now') returns UTC without a timezone marker; treat it as UTC.
|
|
const ts = Date.parse(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z')
|
|
if (Number.isNaN(ts)) return iso
|
|
const diffMs = Date.now() - ts
|
|
const sec = Math.round(diffMs / 1000)
|
|
if (sec < 60) return 'just now'
|
|
const min = Math.round(sec / 60)
|
|
if (min < 60) return `${min}m ago`
|
|
const hr = Math.round(min / 60)
|
|
if (hr < 24) return `${hr}h ago`
|
|
const day = Math.round(hr / 24)
|
|
if (day < 30) return `${day}d ago`
|
|
return new Date(ts).toLocaleDateString()
|
|
}
|
|
|
|
function describeUserAgent(ua: string | null): string {
|
|
if (!ua) return 'Unknown device'
|
|
let os = 'Unknown OS'
|
|
if (/Windows/i.test(ua)) os = 'Windows'
|
|
else if (/Macintosh|Mac OS/i.test(ua)) os = 'macOS'
|
|
else if (/Android/i.test(ua)) os = 'Android'
|
|
else if (/iPhone|iPad|iOS/i.test(ua)) os = 'iOS'
|
|
else if (/Linux/i.test(ua)) os = 'Linux'
|
|
let browser = ''
|
|
if (/Edg\//i.test(ua)) browser = 'Edge'
|
|
else if (/Chrome\//i.test(ua) && !/Chromium/i.test(ua)) browser = 'Chrome'
|
|
else if (/Firefox\//i.test(ua)) browser = 'Firefox'
|
|
else if (/Safari\//i.test(ua) && !/Chrome/i.test(ua)) browser = 'Safari'
|
|
return browser ? `${browser} on ${os}` : os
|
|
}
|
|
|
|
function SecuritySection() {
|
|
// Change-password form
|
|
const [currentPassword, setCurrentPassword] = useState('')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [confirmPassword, setConfirmPassword] = useState('')
|
|
const [showCurrent, setShowCurrent] = useState(false)
|
|
const [showNew, setShowNew] = useState(false)
|
|
const [changing, setChanging] = useState(false)
|
|
const [pwMsg, setPwMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
|
|
|
// Sessions + login events
|
|
const [sessions, setSessions] = useState<AuthSession[]>([])
|
|
const [events, setEvents] = useState<LoginEvent[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
|
|
async function loadActivity() {
|
|
try {
|
|
const [s, e] = await Promise.all([api.listSessions(), api.listLoginEvents(15)])
|
|
setSessions(s.sessions)
|
|
setEvents(e.events)
|
|
} catch {
|
|
// leave existing data on transient failure
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadActivity()
|
|
}, [])
|
|
|
|
async function handleChangePassword() {
|
|
setPwMsg(null)
|
|
if (newPassword.length < 8) {
|
|
setPwMsg({ text: 'New password must be at least 8 characters', ok: false })
|
|
return
|
|
}
|
|
if (newPassword !== confirmPassword) {
|
|
setPwMsg({ text: 'New passwords do not match', ok: false })
|
|
return
|
|
}
|
|
setChanging(true)
|
|
try {
|
|
await api.changePassword(currentPassword, newPassword)
|
|
setPwMsg({ text: 'Password changed. Other sessions were signed out.', ok: true })
|
|
setCurrentPassword('')
|
|
setNewPassword('')
|
|
setConfirmPassword('')
|
|
loadActivity()
|
|
} catch (err) {
|
|
setPwMsg({ text: err instanceof ApiError ? err.message : 'Failed to change password', ok: false })
|
|
} finally {
|
|
setChanging(false)
|
|
}
|
|
}
|
|
|
|
async function handleRevoke(id: string) {
|
|
try {
|
|
await api.revokeSession(id)
|
|
setSessions((prev) => prev.filter((s) => s.id !== id))
|
|
} catch {
|
|
loadActivity()
|
|
}
|
|
}
|
|
|
|
const pwInputWrap: React.CSSProperties = { position: 'relative' }
|
|
const eyeBtn: React.CSSProperties = {
|
|
position: 'absolute',
|
|
right: '10px',
|
|
top: '50%',
|
|
transform: 'translateY(-50%)',
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
color: '#7A7D85',
|
|
display: 'flex',
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
{/* Change password */}
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Change Password</h3>
|
|
<div className="flex flex-col gap-4" style={{ maxWidth: '420px' }}>
|
|
<div>
|
|
<label style={labelStyle}>Current Password</label>
|
|
<div style={pwInputWrap}>
|
|
<input
|
|
style={{ ...inputStyle, paddingRight: '38px' }}
|
|
type={showCurrent ? 'text' : 'password'}
|
|
autoComplete="current-password"
|
|
value={currentPassword}
|
|
onChange={(e) => setCurrentPassword(e.target.value)}
|
|
/>
|
|
<button style={eyeBtn} onClick={() => setShowCurrent((v) => !v)} type="button" title={showCurrent ? 'Hide' : 'Show'}>
|
|
{showCurrent ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>New Password</label>
|
|
<div style={pwInputWrap}>
|
|
<input
|
|
style={{ ...inputStyle, paddingRight: '38px' }}
|
|
type={showNew ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
value={newPassword}
|
|
onChange={(e) => setNewPassword(e.target.value)}
|
|
/>
|
|
<button style={eyeBtn} onClick={() => setShowNew((v) => !v)} type="button" title={showNew ? 'Hide' : 'Show'}>
|
|
{showNew ? <EyeOff size={15} /> : <Eye size={15} />}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Confirm New Password</label>
|
|
<input
|
|
style={inputStyle}
|
|
type={showNew ? 'text' : 'password'}
|
|
autoComplete="new-password"
|
|
value={confirmPassword}
|
|
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
/>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<GoldButton
|
|
onClick={handleChangePassword}
|
|
disabled={changing || !currentPassword || !newPassword || !confirmPassword}
|
|
>
|
|
{changing ? 'Saving…' : 'Update Password'}
|
|
</GoldButton>
|
|
{pwMsg && (
|
|
<span style={{ fontSize: '12px', color: pwMsg.ok ? '#2ECC71' : '#E74C3C' }}>{pwMsg.text}</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Active sessions */}
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Active Sessions</h3>
|
|
{loading ? (
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading…</p>
|
|
) : sessions.length === 0 ? (
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No active sessions.</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{sessions.map((s) => (
|
|
<div
|
|
key={s.id}
|
|
className="flex items-center gap-3"
|
|
style={{
|
|
padding: '12px 14px',
|
|
borderRadius: '8px',
|
|
border: '1px solid rgba(200,164,52,0.08)',
|
|
backgroundColor: 'rgba(255,255,255,0.02)',
|
|
}}
|
|
>
|
|
<Monitor size={18} color={s.current ? '#C8A434' : '#7A7D85'} style={{ flexShrink: 0 }} />
|
|
<div className="flex-1 min-w-0">
|
|
<div style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>
|
|
{describeUserAgent(s.userAgent)}
|
|
{s.current && (
|
|
<span style={{ fontSize: '10px', color: '#C8A434', marginLeft: '8px', fontWeight: 600 }}>THIS DEVICE</span>
|
|
)}
|
|
</div>
|
|
<div style={{ fontSize: '11px', color: '#7A7D85' }}>
|
|
{s.ip ?? 'unknown IP'} · last active {relativeTime(s.lastSeenAt)}
|
|
</div>
|
|
</div>
|
|
{!s.current && (
|
|
<button
|
|
onClick={() => handleRevoke(s.id)}
|
|
className="flex items-center gap-1.5 cursor-pointer transition-colors whitespace-nowrap"
|
|
style={{
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
color: '#E74C3C',
|
|
background: 'transparent',
|
|
border: '1px solid rgba(231,76,60,0.4)',
|
|
borderRadius: '7px',
|
|
padding: '6px 12px',
|
|
}}
|
|
>
|
|
<LogOut size={13} /> Sign out
|
|
</button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Login activity */}
|
|
<div style={cardBase}>
|
|
<h3 style={sectionTitle}>Recent Login Activity</h3>
|
|
{loading ? (
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading…</p>
|
|
) : events.length === 0 ? (
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>No login activity recorded yet.</p>
|
|
) : (
|
|
<div className="flex flex-col">
|
|
{events.map((e, i) => (
|
|
<div
|
|
key={e.id}
|
|
className="flex items-center gap-3"
|
|
style={{
|
|
padding: '10px 0',
|
|
borderTop: i === 0 ? 'none' : '1px solid rgba(200,164,52,0.06)',
|
|
}}
|
|
>
|
|
<span
|
|
style={{
|
|
width: '8px',
|
|
height: '8px',
|
|
borderRadius: '50%',
|
|
backgroundColor: e.success ? '#2ECC71' : '#E74C3C',
|
|
flexShrink: 0,
|
|
}}
|
|
/>
|
|
<div className="flex-1 min-w-0">
|
|
<span style={{ fontSize: '13px', color: '#E8E6E0' }}>
|
|
{e.success ? 'Successful login' : 'Failed login'}
|
|
</span>
|
|
<span style={{ fontSize: '11px', color: '#7A7D85', marginLeft: '8px' }}>
|
|
{e.username ?? 'unknown'} · {e.ip ?? 'unknown IP'}
|
|
</span>
|
|
</div>
|
|
<span style={{ fontSize: '11px', color: '#7A7D85', flexShrink: 0 }}>{relativeTime(e.createdAt)}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p style={{ fontSize: '11px', color: '#7A7D85', marginTop: '14px' }}>
|
|
SSO (Authentik) and multi-user accounts are planned — see the project roadmap.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MAX_USERS = 10
|
|
|
|
function UsersSection() {
|
|
const { user: currentUser } = useAuth()
|
|
const [users, setUsers] = useState<ManagedUser[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [error, setError] = useState('')
|
|
|
|
// Create-user form
|
|
const [showCreate, setShowCreate] = useState(false)
|
|
const [newUsername, setNewUsername] = useState('')
|
|
const [newPassword, setNewPassword] = useState('')
|
|
const [newRole, setNewRole] = useState<'admin' | 'member'>('member')
|
|
const [creating, setCreating] = useState(false)
|
|
const [createMsg, setCreateMsg] = useState<{ text: string; ok: boolean } | null>(null)
|
|
|
|
async function load() {
|
|
try {
|
|
const { users } = await api.listUsers()
|
|
setUsers(users)
|
|
setError('')
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Failed to load users')
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
load()
|
|
}, [])
|
|
|
|
async function handleCreate() {
|
|
setCreateMsg(null)
|
|
if (newUsername.length < 3) {
|
|
setCreateMsg({ text: 'Username must be at least 3 characters', ok: false })
|
|
return
|
|
}
|
|
if (newPassword.length < 8) {
|
|
setCreateMsg({ text: 'Temporary password must be at least 8 characters', ok: false })
|
|
return
|
|
}
|
|
setCreating(true)
|
|
try {
|
|
await api.createUser({ username: newUsername, password: newPassword, role: newRole })
|
|
setCreateMsg({ text: `User "${newUsername}" created`, ok: true })
|
|
setNewUsername('')
|
|
setNewPassword('')
|
|
setNewRole('member')
|
|
setShowCreate(false)
|
|
load()
|
|
} catch (err) {
|
|
setCreateMsg({ text: err instanceof ApiError ? err.message : 'Failed to create user', ok: false })
|
|
} finally {
|
|
setCreating(false)
|
|
}
|
|
}
|
|
|
|
async function handleRoleToggle(u: ManagedUser) {
|
|
const nextRole = u.role === 'admin' ? 'member' : 'admin'
|
|
try {
|
|
await api.updateUser(u.id, { role: nextRole })
|
|
load()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Failed to update role')
|
|
}
|
|
}
|
|
|
|
async function handleActiveToggle(u: ManagedUser) {
|
|
try {
|
|
await api.updateUser(u.id, { active: !u.active })
|
|
load()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Failed to update user')
|
|
}
|
|
}
|
|
|
|
async function handleDelete(u: ManagedUser) {
|
|
if (!window.confirm(`Delete user "${u.username}"? This cannot be undone.`)) return
|
|
try {
|
|
await api.deleteUser(u.id)
|
|
load()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Failed to delete user')
|
|
}
|
|
}
|
|
|
|
const atCap = users.length >= MAX_USERS
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5">
|
|
<div style={cardBase}>
|
|
<div className="flex items-center justify-between" style={{ marginBottom: '16px' }}>
|
|
<h3 style={{ ...sectionTitle, marginBottom: 0 }}>
|
|
Users <span style={{ color: '#7A7D85', fontWeight: 400 }}>· {users.length}/{MAX_USERS}</span>
|
|
</h3>
|
|
<GoldButton onClick={() => setShowCreate((v) => !v)} disabled={atCap}>
|
|
<UserPlus size={14} /> {showCreate ? 'Cancel' : 'Add User'}
|
|
</GoldButton>
|
|
</div>
|
|
|
|
{atCap && !showCreate && (
|
|
<p style={{ fontSize: '12px', color: '#E67E22', marginBottom: '14px' }}>
|
|
User limit reached ({MAX_USERS}). Delete a user to add another.
|
|
</p>
|
|
)}
|
|
|
|
{showCreate && (
|
|
<div
|
|
className="flex flex-col gap-3"
|
|
style={{ marginBottom: '18px', padding: '16px', borderRadius: '10px', border: '1px solid rgba(200,164,52,0.12)', backgroundColor: 'rgba(255,255,255,0.02)' }}
|
|
>
|
|
<div className="grid grid-cols-2 gap-3">
|
|
<div>
|
|
<label style={labelStyle}>Username</label>
|
|
<input style={inputStyle} value={newUsername} onChange={(e) => setNewUsername(e.target.value)} autoComplete="off" />
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Temporary Password</label>
|
|
<input style={inputStyle} type="text" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} autoComplete="off" />
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<label style={labelStyle}>Role</label>
|
|
<div className="flex gap-2">
|
|
{(['member', 'admin'] as const).map((r) => (
|
|
<button
|
|
key={r}
|
|
onClick={() => setNewRole(r)}
|
|
className="cursor-pointer transition-colors"
|
|
style={{
|
|
fontSize: '12px',
|
|
textTransform: 'capitalize',
|
|
padding: '7px 16px',
|
|
borderRadius: '8px',
|
|
border: newRole === r ? '1px solid #C8A434' : '1px solid rgba(200,164,52,0.12)',
|
|
backgroundColor: newRole === r ? 'rgba(200,164,52,0.12)' : 'transparent',
|
|
color: newRole === r ? '#C8A434' : '#7A7D85',
|
|
}}
|
|
>
|
|
{r}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
<p style={{ fontSize: '11px', color: '#7A7D85' }}>
|
|
The user signs in with this temporary password and can change it under Security.
|
|
</p>
|
|
<div className="flex items-center gap-3">
|
|
<GoldButton onClick={handleCreate} disabled={creating || !newUsername || !newPassword}>
|
|
{creating ? 'Creating…' : 'Create User'}
|
|
</GoldButton>
|
|
{createMsg && <span style={{ fontSize: '12px', color: createMsg.ok ? '#2ECC71' : '#E74C3C' }}>{createMsg.text}</span>}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginBottom: '12px' }}>{error}</p>}
|
|
|
|
{loading ? (
|
|
<p style={{ fontSize: '13px', color: '#7A7D85' }}>Loading…</p>
|
|
) : (
|
|
<div className="flex flex-col gap-2">
|
|
{users.map((u) => {
|
|
const isSelf = currentUser?.id === u.id
|
|
return (
|
|
<div
|
|
key={u.id}
|
|
className="flex items-center gap-3"
|
|
style={{
|
|
padding: '12px 14px',
|
|
borderRadius: '8px',
|
|
border: '1px solid rgba(200,164,52,0.08)',
|
|
backgroundColor: 'rgba(255,255,255,0.02)',
|
|
opacity: u.active ? 1 : 0.55,
|
|
}}
|
|
>
|
|
<div
|
|
className="rounded-full flex items-center justify-center font-bold shrink-0"
|
|
style={{ width: '34px', height: '34px', fontSize: '12px', color: '#C8A434', border: '1px solid rgba(200,164,52,0.4)', backgroundColor: 'rgba(200,164,52,0.08)' }}
|
|
>
|
|
{(u.displayName || u.username).slice(0, 2).toUpperCase()}
|
|
</div>
|
|
<div className="flex-1 min-w-0">
|
|
<div style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 500 }}>
|
|
{u.username}
|
|
{isSelf && <span style={{ fontSize: '10px', color: '#C8A434', marginLeft: '8px', fontWeight: 600 }}>YOU</span>}
|
|
{!u.active && <span style={{ fontSize: '10px', color: '#E67E22', marginLeft: '8px', fontWeight: 600 }}>DEACTIVATED</span>}
|
|
</div>
|
|
<div style={{ fontSize: '11px', color: '#7A7D85' }}>{u.email || 'No email'}</div>
|
|
</div>
|
|
<button
|
|
onClick={() => handleRoleToggle(u)}
|
|
disabled={isSelf}
|
|
className="cursor-pointer transition-colors shrink-0"
|
|
title={isSelf ? "You can't change your own role" : `Make ${u.role === 'admin' ? 'member' : 'admin'}`}
|
|
style={{
|
|
fontSize: '11px',
|
|
fontWeight: 600,
|
|
textTransform: 'uppercase',
|
|
letterSpacing: '0.5px',
|
|
padding: '5px 10px',
|
|
borderRadius: '6px',
|
|
border: 'none',
|
|
color: u.role === 'admin' ? '#C8A434' : '#7A7D85',
|
|
backgroundColor: u.role === 'admin' ? 'rgba(200,164,52,0.12)' : 'rgba(255,255,255,0.05)',
|
|
opacity: isSelf ? 0.5 : 1,
|
|
cursor: isSelf ? 'default' : 'pointer',
|
|
}}
|
|
>
|
|
{u.role}
|
|
</button>
|
|
{!isSelf && (
|
|
<>
|
|
<button
|
|
onClick={() => handleActiveToggle(u)}
|
|
className="cursor-pointer shrink-0"
|
|
style={{ fontSize: '11px', fontWeight: 600, padding: '6px 11px', borderRadius: '7px', border: '1px solid rgba(200,164,52,0.2)', background: 'transparent', color: '#7A7D85' }}
|
|
>
|
|
{u.active ? 'Deactivate' : 'Activate'}
|
|
</button>
|
|
<button
|
|
onClick={() => handleDelete(u)}
|
|
className="flex items-center cursor-pointer shrink-0"
|
|
title="Delete user"
|
|
style={{ padding: '6px', borderRadius: '7px', border: '1px solid rgba(231,76,60,0.4)', background: 'transparent', color: '#E74C3C' }}
|
|
>
|
|
<Trash2 size={14} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
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,
|
|
about: AboutSection,
|
|
}
|
|
|
|
export default function Settings() {
|
|
const [searchParams, setSearchParams] = useSearchParams()
|
|
const { user } = useAuth()
|
|
const isAdmin = user?.role === 'admin'
|
|
const visibleSections = navSections.filter((s) => !s.adminOnly || isAdmin)
|
|
const requestedTab = searchParams.get('tab')
|
|
const requestedAllowed =
|
|
requestedTab && sectionComponents[requestedTab] && visibleSections.some((s) => s.id === requestedTab)
|
|
const active = requestedAllowed ? requestedTab! : 'profile'
|
|
const ActiveSection = sectionComponents[active]
|
|
|
|
function setActive(id: string) {
|
|
setSearchParams({ tab: id })
|
|
}
|
|
|
|
return (
|
|
<div className="flex h-full w-full gap-5">
|
|
{/* Settings nav */}
|
|
<div className="flex flex-col gap-1 shrink-0" style={{ width: '200px' }}>
|
|
{visibleSections.map((s) => {
|
|
const Icon = s.icon
|
|
const isActive = active === s.id
|
|
return (
|
|
<button
|
|
key={s.id}
|
|
onClick={() => setActive(s.id)}
|
|
className="flex items-center gap-2.5 cursor-pointer border-none bg-transparent transition-colors"
|
|
style={{
|
|
fontSize: '13px',
|
|
fontWeight: 500,
|
|
padding: '10px 14px',
|
|
borderRadius: '8px',
|
|
color: isActive ? '#C8A434' : '#7A7D85',
|
|
backgroundColor: isActive ? 'rgba(200,164,52,0.1)' : 'transparent',
|
|
}}
|
|
>
|
|
<Icon size={15} />
|
|
{s.label}
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
|
|
{/* Content */}
|
|
<div className="flex-1 overflow-y-auto" style={{ scrollbarWidth: 'none' }}>
|
|
<ActiveSection />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|