- New AuthContext drives app state (loading/needs-setup/enrolling/ logged-out/logged-in) by checking GET /api/system/setup-status and GET /api/auth/me on load; JWT stored in localStorage - Enrollment page: step 1 creates the admin account via POST /api/setup, step 2 lets you connect integrations (or skip) before entering the app - Login page for returning sessions; TopBar's Sign Out now calls logout() instead of being a dead link - Verified end-to-end in a browser: fresh setup -> connect/skip -> dashboard, reload persists the session, sign out -> login -> back in Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF
275 lines
8.9 KiB
TypeScript
275 lines
8.9 KiB
TypeScript
import { useState } from 'react'
|
|
import { Server, Container, Network, Cloud, CloudCog, Activity, CloudSun, Check, ArrowRight } from 'lucide-react'
|
|
import { useAuth } from '../lib/AuthContext'
|
|
import { api, ApiError } from '../lib/api'
|
|
|
|
const cardBase: React.CSSProperties = {
|
|
backgroundColor: 'rgba(10, 10, 12, 0.92)',
|
|
border: '1px solid rgba(200, 164, 52, 0.12)',
|
|
borderRadius: '14px',
|
|
padding: '32px',
|
|
}
|
|
|
|
const fieldLabel: React.CSSProperties = {
|
|
fontSize: '11px',
|
|
color: '#7A7D85',
|
|
marginBottom: '6px',
|
|
display: 'block',
|
|
}
|
|
|
|
const fieldInput: React.CSSProperties = {
|
|
width: '100%',
|
|
height: '36px',
|
|
borderRadius: '8px',
|
|
border: '1px solid rgba(200,164,52,0.12)',
|
|
backgroundColor: 'rgba(255,255,255,0.03)',
|
|
color: '#E8E6E0',
|
|
fontSize: '13px',
|
|
padding: '0 12px',
|
|
outline: 'none',
|
|
}
|
|
|
|
const goldButton: React.CSSProperties = {
|
|
height: '38px',
|
|
borderRadius: '8px',
|
|
border: 'none',
|
|
fontSize: '13px',
|
|
fontWeight: 600,
|
|
color: '#0A0B0D',
|
|
backgroundColor: '#C8A434',
|
|
boxShadow: '0 0 14px rgba(200,164,52,0.2)',
|
|
padding: '0 20px',
|
|
}
|
|
|
|
const integrationOptions = [
|
|
{ type: 'proxmox', name: 'Proxmox', icon: Server },
|
|
{ type: 'docker', name: 'Docker', icon: Container },
|
|
{ type: 'netbird', name: 'NetBird', icon: Network },
|
|
{ type: 'cloudflare', name: 'Cloudflare', icon: Cloud },
|
|
{ type: 'aws', name: 'AWS', icon: CloudCog },
|
|
{ type: 'uptime_kuma', name: 'Uptime Kuma', icon: Activity },
|
|
{ type: 'weather', name: 'Weather API', icon: CloudSun },
|
|
] as const
|
|
|
|
export default function Enrollment() {
|
|
const { status, completeSetup, finishEnrollment } = useAuth()
|
|
const step = status === 'enrolling' ? 'connect' : 'account'
|
|
|
|
return (
|
|
<div className="flex h-screen w-screen items-center justify-center bg-page">
|
|
<div style={{ width: step === 'account' ? '380px' : '640px' }}>
|
|
{step === 'account' ? (
|
|
<AccountStep completeSetup={completeSetup} />
|
|
) : (
|
|
<ConnectStep onFinish={finishEnrollment} />
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function AccountStep({
|
|
completeSetup,
|
|
}: {
|
|
completeSetup: (username: string, password: string) => Promise<void>
|
|
}) {
|
|
const [username, setUsername] = useState('')
|
|
const [password, setPassword] = useState('')
|
|
const [confirm, setConfirm] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
if (password !== confirm) {
|
|
setError('Passwords do not match')
|
|
return
|
|
}
|
|
if (password.length < 8) {
|
|
setError('Password must be at least 8 characters')
|
|
return
|
|
}
|
|
setSubmitting(true)
|
|
try {
|
|
await completeSetup(username, password)
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Setup failed')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit} style={cardBase}>
|
|
<h1
|
|
style={{
|
|
fontSize: '20px',
|
|
fontWeight: 700,
|
|
letterSpacing: '1px',
|
|
textTransform: 'uppercase',
|
|
color: '#C8A434',
|
|
marginBottom: '4px',
|
|
}}
|
|
>
|
|
Welcome to ArchNest
|
|
</h1>
|
|
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
|
Create your admin account to get started
|
|
</p>
|
|
|
|
<label style={fieldLabel}>Username</label>
|
|
<input style={fieldInput} value={username} onChange={(e) => setUsername(e.target.value)} autoFocus required />
|
|
|
|
<label style={{ ...fieldLabel, marginTop: '14px' }}>Password</label>
|
|
<input style={fieldInput} type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
|
|
|
<label style={{ ...fieldLabel, marginTop: '14px' }}>Confirm Password</label>
|
|
<input style={fieldInput} type="password" value={confirm} onChange={(e) => setConfirm(e.target.value)} required />
|
|
|
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
|
|
|
<button type="submit" disabled={submitting} style={{ ...goldButton, width: '100%', marginTop: '22px', opacity: submitting ? 0.6 : 1 }}>
|
|
{submitting ? 'Creating account…' : 'Create Account'}
|
|
</button>
|
|
</form>
|
|
)
|
|
}
|
|
|
|
function ConnectStep({ onFinish }: { onFinish: () => Promise<void> }) {
|
|
const [connected, setConnected] = useState<Set<string>>(new Set())
|
|
const [active, setActive] = useState<(typeof integrationOptions)[number] | null>(null)
|
|
|
|
return (
|
|
<div style={cardBase}>
|
|
<h1
|
|
style={{
|
|
fontSize: '18px',
|
|
fontWeight: 700,
|
|
letterSpacing: '1px',
|
|
textTransform: 'uppercase',
|
|
color: '#C8A434',
|
|
marginBottom: '4px',
|
|
}}
|
|
>
|
|
Connect Your Services
|
|
</h1>
|
|
<p style={{ fontSize: '12px', color: '#7A7D85', marginBottom: '24px' }}>
|
|
Add the integrations you want ArchNest to monitor. You can also do this later from Settings.
|
|
</p>
|
|
|
|
{active ? (
|
|
<ConnectForm
|
|
option={active}
|
|
onConnected={() => {
|
|
setConnected((prev) => new Set(prev).add(active.type))
|
|
setActive(null)
|
|
}}
|
|
onCancel={() => setActive(null)}
|
|
/>
|
|
) : (
|
|
<div className="grid grid-cols-3 gap-3">
|
|
{integrationOptions.map((opt) => {
|
|
const Icon = opt.icon
|
|
const isDone = connected.has(opt.type)
|
|
return (
|
|
<button
|
|
key={opt.type}
|
|
onClick={() => setActive(opt)}
|
|
className="flex flex-col items-center gap-2 cursor-pointer transition-colors"
|
|
style={{
|
|
border: `1px solid ${isDone ? 'rgba(46,204,113,0.4)' : 'rgba(200,164,52,0.12)'}`,
|
|
borderRadius: '10px',
|
|
padding: '18px 8px',
|
|
backgroundColor: 'rgba(255,255,255,0.02)',
|
|
}}
|
|
>
|
|
{isDone ? <Check size={20} color="#2ECC71" /> : <Icon size={20} color="#C8A434" />}
|
|
<span style={{ fontSize: '11px', color: '#E8E6E0' }}>{opt.name}</span>
|
|
</button>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{!active && (
|
|
<div className="flex justify-end" style={{ marginTop: '24px' }}>
|
|
<button
|
|
onClick={() => onFinish()}
|
|
className="cursor-pointer"
|
|
style={{ ...goldButton, display: 'inline-flex', alignItems: 'center', gap: '8px' }}
|
|
>
|
|
{connected.size > 0 ? 'Finish' : 'Skip for now'} <ArrowRight size={14} />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ConnectForm({
|
|
option,
|
|
onConnected,
|
|
onCancel,
|
|
}: {
|
|
option: (typeof integrationOptions)[number]
|
|
onConnected: () => void
|
|
onCancel: () => void
|
|
}) {
|
|
const [baseUrl, setBaseUrl] = useState('')
|
|
const [apiKey, setApiKey] = useState('')
|
|
const [error, setError] = useState<string | null>(null)
|
|
const [testResult, setTestResult] = useState<string | null>(null)
|
|
const [submitting, setSubmitting] = useState(false)
|
|
|
|
async function handleSubmit(e: React.FormEvent) {
|
|
e.preventDefault()
|
|
setError(null)
|
|
setTestResult(null)
|
|
setSubmitting(true)
|
|
try {
|
|
const { integration } = await api.createIntegration({
|
|
type: option.type,
|
|
name: option.name,
|
|
config: baseUrl ? { baseUrl } : {},
|
|
secrets: apiKey ? { apiKey } : {},
|
|
})
|
|
const result = await api.testIntegration(integration.id)
|
|
setTestResult(result.message)
|
|
if (result.ok) onConnected()
|
|
} catch (err) {
|
|
setError(err instanceof ApiError ? err.message : 'Failed to add integration')
|
|
} finally {
|
|
setSubmitting(false)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<form onSubmit={handleSubmit}>
|
|
<p style={{ fontSize: '13px', color: '#E8E6E0', marginBottom: '16px', fontWeight: 600 }}>{option.name}</p>
|
|
|
|
<label style={fieldLabel}>Base URL</label>
|
|
<input style={fieldInput} value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} placeholder="https://..." />
|
|
|
|
<label style={{ ...fieldLabel, marginTop: '14px' }}>API Key (optional)</label>
|
|
<input style={fieldInput} type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} />
|
|
|
|
{error && <p style={{ fontSize: '12px', color: '#E74C3C', marginTop: '12px' }}>{error}</p>}
|
|
{testResult && <p style={{ fontSize: '12px', color: '#7A7D85', marginTop: '12px' }}>{testResult}</p>}
|
|
|
|
<div className="flex gap-3" style={{ marginTop: '20px' }}>
|
|
<button type="submit" disabled={submitting} style={{ ...goldButton, opacity: submitting ? 0.6 : 1 }}>
|
|
{submitting ? 'Connecting…' : 'Connect'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={onCancel}
|
|
className="cursor-pointer"
|
|
style={{ height: '38px', borderRadius: '8px', border: '1px solid rgba(255,255,255,0.12)', background: 'transparent', color: '#7A7D85', fontSize: '13px', padding: '0 16px' }}
|
|
>
|
|
Cancel
|
|
</button>
|
|
</div>
|
|
</form>
|
|
)
|
|
}
|