From e2793b06fef88dab71232c9bb3456b93dad54b2e Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 18 Jun 2026 19:13:27 +0000 Subject: [PATCH] Add enrollment, login, and auth-gated routing to the frontend - 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 Claude-Session: https://claude.ai/code/session_01BbJV5nm8KPVH1oNJYKpnoF --- src/App.tsx | 19 +++ src/components/TopBar.tsx | 9 +- src/lib/AuthContext.tsx | 83 ++++++++++++ src/lib/api.ts | 96 +++++++++++++ src/main.tsx | 5 +- src/pages/Enrollment.tsx | 275 ++++++++++++++++++++++++++++++++++++++ src/pages/Login.tsx | 115 ++++++++++++++++ 7 files changed, 599 insertions(+), 3 deletions(-) create mode 100644 src/lib/AuthContext.tsx create mode 100644 src/lib/api.ts create mode 100644 src/pages/Enrollment.tsx create mode 100644 src/pages/Login.tsx diff --git a/src/App.tsx b/src/App.tsx index e66c51b..e714610 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -6,8 +6,27 @@ import Glance from './pages/Glance' import Infrastructure from './pages/Infrastructure' import BookNest from './pages/BookNest' import Settings from './pages/Settings' +import Login from './pages/Login' +import Enrollment from './pages/Enrollment' +import { useAuth } from './lib/AuthContext' function App() { + const { status } = useAuth() + + if (status === 'loading') { + return ( +
+

Loading…

+
+ ) + } + if (status === 'needs-setup' || status === 'enrolling') return + if (status === 'logged-out') return + + return +} + +function Dashboard() { const [sidebarCollapsed, setSidebarCollapsed] = useState(false) const sidebarWidth = sidebarCollapsed ? 64 : 200 const location = useLocation() diff --git a/src/components/TopBar.tsx b/src/components/TopBar.tsx index 483989f..1c24618 100644 --- a/src/components/TopBar.tsx +++ b/src/components/TopBar.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from 'react' import { useLocation } from 'react-router-dom' import { Search, Bell, ChevronDown, User, Palette, LogOut, Shield, HelpCircle } from 'lucide-react' +import { useAuth } from '../lib/AuthContext' const pageTitles: Record = { '/': 'Glance', @@ -15,6 +16,7 @@ const pageSubtitles: Record = { } export default function TopBar() { + const { logout } = useAuth() const [userMenuOpen, setUserMenuOpen] = useState(false) const menuRef = useRef(null) const location = useLocation() @@ -113,10 +115,13 @@ export default function TopBar() { )} diff --git a/src/lib/AuthContext.tsx b/src/lib/AuthContext.tsx new file mode 100644 index 0000000..d37de0c --- /dev/null +++ b/src/lib/AuthContext.tsx @@ -0,0 +1,83 @@ +import { createContext, useContext, useEffect, useState, type ReactNode } from 'react' +import { api, getToken, setToken } from './api' + +type AuthStatus = 'loading' | 'needs-setup' | 'enrolling' | 'logged-out' | 'logged-in' + +interface AuthUser { + id: number + username: string + display_name: string | null + email: string | null + avatar_data_url: string | null +} + +interface AuthContextValue { + status: AuthStatus + user: AuthUser | null + login: (username: string, password: string) => Promise + completeSetup: (username: string, password: string) => Promise + finishEnrollment: () => Promise + logout: () => void +} + +const AuthContext = createContext(null) + +export function AuthProvider({ children }: { children: ReactNode }) { + const [status, setStatus] = useState('loading') + const [user, setUser] = useState(null) + + async function refresh() { + if (getToken()) { + try { + const { user } = await api.me() + setUser(user) + setStatus('logged-in') + return + } catch { + setToken(null) + } + } + const { needsSetup } = await api.getSetupStatus() + setStatus(needsSetup ? 'needs-setup' : 'logged-out') + } + + useEffect(() => { + refresh() + }, []) + + async function login(username: string, password: string) { + const { token } = await api.login(username, password) + setToken(token) + await refresh() + } + + async function completeSetup(username: string, password: string) { + const { token } = await api.setup(username, password) + setToken(token) + const { user } = await api.me() + setUser(user) + setStatus('enrolling') + } + + async function finishEnrollment() { + await refresh() + } + + function logout() { + setToken(null) + setUser(null) + setStatus('logged-out') + } + + return ( + + {children} + + ) +} + +export function useAuth() { + const ctx = useContext(AuthContext) + if (!ctx) throw new Error('useAuth must be used within AuthProvider') + return ctx +} diff --git a/src/lib/api.ts b/src/lib/api.ts new file mode 100644 index 0000000..84b0835 --- /dev/null +++ b/src/lib/api.ts @@ -0,0 +1,96 @@ +const TOKEN_KEY = 'archnest_token' + +export function getToken(): string | null { + return localStorage.getItem(TOKEN_KEY) +} + +export function setToken(token: string | null) { + if (token) localStorage.setItem(TOKEN_KEY, token) + else localStorage.removeItem(TOKEN_KEY) +} + +export class ApiError extends Error { + status: number + constructor(status: number, message: string) { + super(message) + this.status = status + } +} + +export async function apiFetch(path: string, options: RequestInit = {}): Promise { + const token = getToken() + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record | undefined), + } + if (token) headers.Authorization = `Bearer ${token}` + + const res = await fetch(`/api${path}`, { ...options, headers }) + + if (!res.ok) { + let message = res.statusText + try { + const body = await res.json() + message = body.error ?? message + } catch { + // ignore non-JSON error bodies + } + throw new ApiError(res.status, message) + } + + if (res.status === 204) return undefined as T + return res.json() as Promise +} + +export const api = { + getSetupStatus: () => apiFetch<{ needsSetup: boolean }>('/system/setup-status'), + setup: (username: string, password: string) => + apiFetch<{ token: string }>('/setup', { method: 'POST', body: JSON.stringify({ username, password }) }), + login: (username: string, password: string) => + apiFetch<{ token: string }>('/auth/login', { method: 'POST', body: JSON.stringify({ username, password }) }), + me: () => apiFetch<{ user: { id: number; username: string; display_name: string | null; email: string | null; avatar_data_url: string | null } }>('/auth/me'), + + listIntegrations: () => apiFetch<{ integrations: Integration[] }>('/integrations'), + createIntegration: (data: { type: string; name: string; config?: Record; secrets?: Record }) => + apiFetch<{ integration: Integration }>('/integrations', { method: 'POST', body: JSON.stringify(data) }), + updateIntegration: (id: number, data: Partial<{ name: string; config: Record; secrets: Record }>) => + apiFetch<{ integration: Integration }>(`/integrations/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + deleteIntegration: (id: number) => apiFetch(`/integrations/${id}`, { method: 'DELETE' }), + testIntegration: (id: number) => apiFetch<{ ok: boolean; message: string }>(`/integrations/${id}/test`, { method: 'POST' }), + + listBookmarks: () => apiFetch<{ bookmarks: Bookmark[] }>('/bookmarks'), + listBookmarkCategories: () => apiFetch<{ categories: BookmarkCategory[] }>('/bookmarks/categories'), + createBookmark: (data: { categoryId?: number | null; title: string; url: string; icon?: string; favorite?: boolean }) => + apiFetch<{ id: number }>('/bookmarks', { method: 'POST', body: JSON.stringify(data) }), + deleteBookmark: (id: number) => apiFetch(`/bookmarks/${id}`, { method: 'DELETE' }), +} + +export interface Integration { + id: number + type: string + name: string + enabled: boolean + status: string + config: Record + lastCheckedAt: string | null + createdAt: string +} + +export interface Bookmark { + id: number + category_id: number | null + title: string + url: string + icon: string | null + favorite: number + status: string + last_checked_at: string | null + created_at: string +} + +export interface BookmarkCategory { + id: number + name: string + icon: string | null + sort_order: number +} diff --git a/src/main.tsx b/src/main.tsx index ade9d64..a688069 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -3,11 +3,14 @@ import { createRoot } from 'react-dom/client' import { BrowserRouter } from 'react-router-dom' import './index.css' import App from './App.tsx' +import { AuthProvider } from './lib/AuthContext' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/src/pages/Enrollment.tsx b/src/pages/Enrollment.tsx new file mode 100644 index 0000000..c9e2259 --- /dev/null +++ b/src/pages/Enrollment.tsx @@ -0,0 +1,275 @@ +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 ( +
+
+ {step === 'account' ? ( + + ) : ( + + )} +
+
+ ) +} + +function AccountStep({ + completeSetup, +}: { + completeSetup: (username: string, password: string) => Promise +}) { + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState(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 ( +
+

+ Welcome to ArchNest +

+

+ Create your admin account to get started +

+ + + setUsername(e.target.value)} autoFocus required /> + + + setPassword(e.target.value)} required /> + + + setConfirm(e.target.value)} required /> + + {error &&

{error}

} + + +
+ ) +} + +function ConnectStep({ onFinish }: { onFinish: () => Promise }) { + const [connected, setConnected] = useState>(new Set()) + const [active, setActive] = useState<(typeof integrationOptions)[number] | null>(null) + + return ( +
+

+ Connect Your Services +

+

+ Add the integrations you want ArchNest to monitor. You can also do this later from Settings. +

+ + {active ? ( + { + setConnected((prev) => new Set(prev).add(active.type)) + setActive(null) + }} + onCancel={() => setActive(null)} + /> + ) : ( +
+ {integrationOptions.map((opt) => { + const Icon = opt.icon + const isDone = connected.has(opt.type) + return ( + + ) + })} +
+ )} + + {!active && ( +
+ +
+ )} +
+ ) +} + +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(null) + const [testResult, setTestResult] = useState(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 ( +
+

{option.name}

+ + + setBaseUrl(e.target.value)} placeholder="https://..." /> + + + setApiKey(e.target.value)} /> + + {error &&

{error}

} + {testResult &&

{testResult}

} + +
+ + +
+
+ ) +} diff --git a/src/pages/Login.tsx b/src/pages/Login.tsx new file mode 100644 index 0000000..92f4577 --- /dev/null +++ b/src/pages/Login.tsx @@ -0,0 +1,115 @@ +import { useState } from 'react' +import { useAuth } from '../lib/AuthContext' +import { ApiError } from '../lib/api' + +export default function Login() { + const { login } = useAuth() + const [username, setUsername] = useState('') + const [password, setPassword] = useState('') + const [error, setError] = useState(null) + const [submitting, setSubmitting] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError(null) + setSubmitting(true) + try { + await login(username, password) + } catch (err) { + setError(err instanceof ApiError ? err.message : 'Login failed') + } finally { + setSubmitting(false) + } + } + + return ( +
+
+

+ ArchNest +

+

Sign in to your dashboard

+ + + setUsername(e.target.value)} + autoFocus + required + /> + + + setPassword(e.target.value)} + required + /> + + {error && ( +

{error}

+ )} + + +
+
+ ) +} + +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', +}