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