* Add editable display-name field to generic integrations Lets users set a custom name for Proxmox, Docker, AWS, Remote Desktop, Netbird, Cloudflare, Uptime Kuma, and Weather integrations, separate from the host/IP field, mirroring the SSH host rename pattern. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Surface the new-integration name field as a labeled input The name field for new generic integrations was a faint header input with only placeholder text, easy to miss. Move it into the form grid as a proper labeled "Name" field next to the other connection fields. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016kF4hZWEkRCPPvCZTeXxn4 * Add file upload for SSH private key and certificate fields Lets users pick a key file from disk (e.g. ~/.ssh) instead of pasting its contents into the Private Key / OPKSSH Certificate fields. * Fix SSH private key paste corrupting multi-line PEM format Private Key and Certificate fields were single-line <input> elements, which strip newlines on paste and corrupt PEM-formatted keys (causing 'Unsupported key format' errors). Render them as multi-line textareas instead so pasted keys keep their line breaks. * Show saved indicator for secret fields instead of appearing blank/deleted GET /api/integrations never returns decrypted secret values (by design), so after navigating away and back, secret/key fields rendered empty - looking exactly like the saved key had been deleted, even though it was still intact and encrypted in the database. Expose which secret keys exist (names only, never values) via secretKeys, and use it to label fields as "saved" with an appropriate placeholder instead of blank. --------- Co-authored-by: Claude <noreply@anthropic.com>
1296 lines
51 KiB
TypeScript
1296 lines
51 KiB
TypeScript
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,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
} 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; 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>>({})
|
|
|
|
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 }) => 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>, 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 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>, 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">
|
|
<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,
|
|
)}
|
|
</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),
|
|
'',
|
|
)}
|
|
</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>
|
|
)
|
|
}
|