dev_arc_aws/src/pages/Settings.tsx
Claude 628187befb
Show a host-specific Docker remote-API setup script in Settings
When adding/editing a Docker integration with a tcp:// or http:// remote
URL, display a copyable systemd override + curl verification script
scoped to the entered host:port, so enabling the daemon's API doesn't
require looking up the steps separately.
2026-06-20 22:15:43 +00:00

2114 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: 'apiKey', label: 'API Key', 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 &amp; 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>
)
}