diff --git a/web/src/App.tsx b/web/src/App.tsx index 991e2c7a8..6ae6fe6cb 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,6 +12,7 @@ import Config from './pages/Config'; import Cost from './pages/Cost'; import Logs from './pages/Logs'; import Doctor from './pages/Doctor'; +import Pairing from './pages/Pairing'; import { AuthProvider, useAuth } from './hooks/useAuth'; import { DraftContext, useDraftStore } from './hooks/useDraft'; import { setLocale, type Locale } from './lib/i18n'; @@ -201,6 +202,7 @@ function AppContent() { } /> } /> } /> + } /> } /> diff --git a/web/src/hooks/useDevices.ts b/web/src/hooks/useDevices.ts new file mode 100644 index 000000000..d879cbe98 --- /dev/null +++ b/web/src/hooks/useDevices.ts @@ -0,0 +1,44 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface Device { + id: string; + name: string | null; + device_type: string | null; + paired_at: string; + last_seen: string; + ip_address: string | null; +} + +export function useDevices() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const token = localStorage.getItem('zeroclaw_token') || ''; + + const fetchDevices = useCallback(async () => { + try { + setLoading(true); + const res = await fetch('/api/devices', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setDevices(data.devices || []); + setError(null); + } else { + setError(`HTTP ${res.status}`); + } + } catch (err) { + setError(err instanceof Error ? err.message : 'Unknown error'); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + fetchDevices(); + }, [fetchDevices]); + + return { devices, loading, error, refetch: fetchDevices }; +} diff --git a/web/src/pages/Pairing.tsx b/web/src/pages/Pairing.tsx new file mode 100644 index 000000000..c75efd4ad --- /dev/null +++ b/web/src/pages/Pairing.tsx @@ -0,0 +1,154 @@ +import { useState, useEffect, useCallback } from 'react'; + +interface Device { + id: string; + name: string | null; + device_type: string | null; + paired_at: string; + last_seen: string; + ip_address: string | null; +} + +export default function Pairing() { + const [devices, setDevices] = useState([]); + const [loading, setLoading] = useState(true); + const [pairingCode, setPairingCode] = useState(null); + const [error, setError] = useState(null); + + const token = localStorage.getItem('zeroclaw_token') || ''; + + const fetchDevices = useCallback(async () => { + try { + const res = await fetch('/api/devices', { + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setDevices(data.devices || []); + } + } catch (err) { + setError('Failed to load devices'); + } finally { + setLoading(false); + } + }, [token]); + + useEffect(() => { + fetchDevices(); + }, [fetchDevices]); + + const handleInitiatePairing = async () => { + try { + const res = await fetch('/api/pairing/initiate', { + method: 'POST', + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + const data = await res.json(); + setPairingCode(data.pairing_code); + } else { + setError('Failed to generate pairing code'); + } + } catch (err) { + setError('Failed to generate pairing code'); + } + }; + + const handleRevokeDevice = async (deviceId: string) => { + try { + const res = await fetch(`/api/devices/${deviceId}`, { + method: 'DELETE', + headers: { Authorization: `Bearer ${token}` }, + }); + if (res.ok) { + setDevices(devices.filter(d => d.id !== deviceId)); + } + } catch (err) { + setError('Failed to revoke device'); + } + }; + + if (loading) { + return
Loading...
; + } + + return ( +
+
+

Device Pairing

+ +
+ + {error && ( +
+ {error} + +
+ )} + + {pairingCode && ( +
+

Pairing Code

+
+ {pairingCode} +
+

+ Enter this code on the new device to complete pairing. +

+
+ )} + +
+
+

Paired Devices ({devices.length})

+
+ {devices.length === 0 ? ( +
+ No devices paired yet. Click "Pair New Device" to get started. +
+ ) : ( + + + + + + + + + + + + + {devices.map(device => ( + + + + + + + + + ))} + +
NameTypePairedLast SeenIPActions
{device.name || 'Unnamed'}{device.device_type || 'Unknown'} + {new Date(device.paired_at).toLocaleDateString()} + + {new Date(device.last_seen).toLocaleString()} + {device.ip_address || '-'} + +
+ )} +
+
+ ); +}