dev_arc_aws/src/pages/Settings.tsx

1185 lines
46 KiB
TypeScript
Raw Normal View History

import { useEffect, useRef, useState } from 'react'
import { api, ApiError, type Integration } from '../lib/api'
import { useAuth } from '../lib/AuthContext'
import {
User,
Palette,
Plug,
Bell,
Database,
Info,
Eye,
EyeOff,
Check,
Download,
Upload,
Trash2,
Camera,
} from 'lucide-react'
const navSections = [
{ id: 'profile', label: 'Profile', icon: User },
{ id: 'appearance', label: 'Appearance', icon: Palette },
{ 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 }
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 },
{ key: 'passphrase', label: 'Key Passphrase', secret: true },
{ key: 'certificate', label: 'OPKSSH Certificate (id_key-cert.pub)', secret: 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 [newDrafts, setNewDrafts] = useState<{ key: number; values: Record<string, string> }[]>([])
const nextNewKey = useRef(-1)
useEffect(() => {
refresh()
}, [])
function refresh() {
api.listIntegrations().then(({ integrations }) => setHosts(integrations.filter((i) => i.type === 'ssh')))
}
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>) {
const config: Record<string, string> = {}
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)
const { integration } = await api.updateIntegration(host.id, { config, secrets })
setHosts((prev) => (prev ?? []).map((h) => (h.id === integration.id ? integration : h)))
setStatusMsg((prev) => ({ ...prev, [host.id]: 'Saved' }))
} 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.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
return (
<div key={key}>
<label style={labelStyle}>{f.label}</label>
<div className="relative">
<input
style={inputStyle}
type={f.secret && !isRevealed ? 'password' : 'text'}
value={value}
onChange={(e) => onChange(f.key, e.target.value)}
placeholder={f.secret && existing ? '••••••••••••' : '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] ?? {}
return (
<div key={host.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',
}}
/>
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{host.name}</span>
</div>
<div className="flex items-center gap-2">
{statusMsg[host.id] && <span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[host.id]}</span>}
<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>
<div className="grid grid-cols-3 gap-4">
{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">
{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 IntegrationsSection() {
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>) {
const config: Record<string, string> = {}
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] ?? {})
const { integration } = await api.updateIntegration(existing.id, { 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 { integration } = await api.createIntegration({ type: def.type, name: def.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) {
return (
<div className="grid grid-cols-3 gap-4">
{def.fields.map((f) => {
const key = `${rowKey}-${f.key}`
const isRevealed = revealed.has(key)
return (
<div key={key}>
<label style={labelStyle}>{f.label}</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 ? 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">
<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',
}}
/>
<span style={{ fontSize: '13px', color: '#E8E6E0', fontWeight: 600 }}>{existing.name || def.name}</span>
{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),
'••••••••••••',
)}
</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>
{renderFields(
def,
rowKey,
(f) => draft.values[f.key] ?? '',
(f, value) => setNewDraftField(draft.id, f.key, value),
'',
)}
</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 [busy, setBusy] = useState(false)
const [message, setMessage] = useState<string | null>(null)
const [error, setError] = useState<string | null>(null)
const importRef = useRef<HTMLInputElement | null>(null)
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>
)
}
const sectionComponents: Record<string, () => React.ReactElement> = {
profile: ProfileSection,
appearance: AppearanceSection,
integrations: IntegrationsSection,
notifications: NotificationsSection,
data: DataBackupSection,
about: AboutSection,
}
export default function Settings() {
const [active, setActive] = useState('profile')
const ActiveSection = sectionComponents[active]
return (
<div className="flex h-full w-full gap-5">
{/* Settings nav */}
<div className="flex flex-col gap-1 shrink-0" style={{ width: '200px' }}>
{navSections.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>
)
}