dev_arc_aws/src/pages/Settings.tsx

1050 lines
38 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,
RotateCcw,
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 }
const integrationTypeDefs: { type: string; name: string; fields: FieldDef[] }[] = [
{ type: 'proxmox', name: 'Proxmox', fields: [{ key: 'baseUrl', label: 'Host URL' }, { key: 'apiKey', label: 'API Token', secret: true }] },
{ type: 'docker', name: 'Docker', fields: [{ key: 'baseUrl', label: 'Socket / Remote URL' }] },
{ type: 'netbird', name: 'NetBird', fields: [{ key: 'apiKey', label: 'API Key', secret: true }] },
{ type: 'cloudflare', name: 'Cloudflare', fields: [{ key: 'apiKey', label: 'API Token', secret: true }, { key: 'zoneId', label: 'Zone ID' }] },
{ type: 'aws', name: 'AWS', fields: [{ key: 'accessKey', label: 'Access Key' }, { key: 'secretKey', label: 'Secret', secret: true }, { key: 'region', label: 'Region' }] },
{ type: 'uptime_kuma', name: 'Uptime Kuma', fields: [{ key: 'baseUrl', label: 'URL' }, { 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', 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>
)
}
function IntegrationsSection() {
const [integrations, setIntegrations] = useState<Integration[] | null>(null)
const [revealed, setRevealed] = useState<Set<string>>(new Set())
const [drafts, setDrafts] = useState<Record<string, Record<string, string>>>({})
const [statusMsg, setStatusMsg] = useState<Record<string, string>>({})
const [busy, setBusy] = useState<Set<string>>(new Set())
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(type: string, value: boolean) {
setBusy((prev) => {
const next = new Set(prev)
if (value) next.add(type)
else next.delete(type)
return next
})
}
function setDraftField(type: string, fieldKey: string, value: string) {
setDrafts((prev) => ({ ...prev, [type]: { ...prev[type], [fieldKey]: value } }))
}
async function handleSave(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
setBusyFlag(def.type, true)
setStatusMsg((prev) => ({ ...prev, [def.type]: '' }))
try {
const draft = drafts[def.type] ?? {}
const config: Record<string, string> = {}
const secrets: Record<string, string> = {}
for (const f of def.fields) {
const value = draft[f.key]
if (value === undefined) continue
if (f.secret) secrets[f.key] = value
else config[f.key] = value
}
let integration: Integration
if (existing) {
;({ integration } = await api.updateIntegration(existing.id, { config, secrets }))
} else {
;({ integration } = await api.createIntegration({ type: def.type, name: def.name, config, secrets }))
}
setIntegrations((prev) => {
const others = (prev ?? []).filter((i) => i.id !== integration.id)
return [...others, integration]
})
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Saved' }))
} catch (err) {
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Save failed' }))
} finally {
setBusyFlag(def.type, false)
}
}
async function handleTest(def: (typeof integrationTypeDefs)[number], existing: Integration | undefined) {
if (!existing) {
setStatusMsg((prev) => ({ ...prev, [def.type]: 'Save the integration before testing' }))
return
}
setBusyFlag(def.type, true)
try {
const result = await api.testIntegration(existing.id)
setStatusMsg((prev) => ({ ...prev, [def.type]: result.message }))
const { integrations } = await api.listIntegrations()
setIntegrations(integrations)
} catch (err) {
setStatusMsg((prev) => ({ ...prev, [def.type]: err instanceof ApiError ? err.message : 'Test failed' }))
} finally {
setBusyFlag(def.type, false)
}
}
if (!integrations) {
return (
<div style={cardBase}>
<p style={{ fontSize: '12px', color: '#7A7D85' }}>Loading integrations</p>
</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 existing = integrations.find((i) => i.type === def.type)
const online = existing?.status === 'connected'
const draft = drafts[def.type] ?? {}
return (
<div key={def.type} 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 }}>{def.name}</span>
</div>
<div className="flex items-center gap-2">
{statusMsg[def.type] && (
<span style={{ fontSize: '11px', color: '#7A7D85' }}>{statusMsg[def.type]}</span>
)}
<button
onClick={() => handleSave(def, existing)}
disabled={busy.has(def.type)}
className="cursor-pointer border-none"
style={{
fontSize: '11px',
fontWeight: 600,
color: '#0A0B0D',
backgroundColor: '#C8A434',
borderRadius: '6px',
padding: '6px 12px',
opacity: busy.has(def.type) ? 0.6 : 1,
}}
>
Save
</button>
<button
onClick={() => handleTest(def, existing)}
disabled={busy.has(def.type)}
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(def.type) ? 0.6 : 1,
}}
>
Test Connection
</button>
</div>
</div>
<div className="grid grid-cols-3 gap-4">
{def.fields.map((f) => {
const key = `${def.type}-${f.key}`
const isRevealed = revealed.has(key)
const savedValue = f.secret ? '' : existing?.config[f.key] ?? ''
const value = draft[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) => setDraftField(def.type, 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>
)
})}
</div>
</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>
)
}