diff --git a/apps/web/src/AccountSettings.tsx b/apps/web/src/AccountSettings.tsx new file mode 100644 index 0000000..14e747b --- /dev/null +++ b/apps/web/src/AccountSettings.tsx @@ -0,0 +1,150 @@ +import { ShieldCheck, UserRound } from 'lucide-react' +import { useEffect, useState } from 'react' + +import { getAccessToken, initializeAuth, keycloak, login, logout, register } from './auth' + +type AccessState = { + status: 'none' | 'pending' | 'approved' | 'rejected' + role: string | null + canApprove: boolean + username: string | null + email: string | null +} + +type AccessRequest = { + subject: string + username: string | null + email: string | null + status: string + requestedAt: string +} + +async function accessFetch(path: string, init: RequestInit = {}) { + const token = await getAccessToken() + if (!token) throw new Error('Authentication unavailable') + const headers = new Headers(init.headers) + headers.set('Authorization', `Bearer ${token}`) + if (init.body) headers.set('Content-Type', 'application/json') + return fetch(path, { ...init, headers }) +} + +export function AccountSettings() { + const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated)) + const [access, setAccess] = useState(null) + const [requests, setRequests] = useState([]) + const [loading, setLoading] = useState(true) + const [message, setMessage] = useState('') + + async function refreshAccess() { + const response = await accessFetch('/api/v1/access/me') + if (!response.ok) throw new Error('Unable to load FieldLens access') + const next = await response.json() as AccessState + setAccess(next) + if (next.canApprove) await refreshRequests() + } + + async function refreshRequests() { + const response = await accessFetch('/api/v1/access/requests') + if (!response.ok) return + const body = await response.json() as { requests: AccessRequest[] } + setRequests(body.requests) + } + + useEffect(() => { + void initializeAuth().then(async (ok) => { + setAuthenticated(ok) + if (ok) { + try { + await refreshAccess() + } catch { + setMessage('Could not load account access.') + } + } + setLoading(false) + }) + }, []) + + async function submitRequest() { + setMessage('') + const response = await accessFetch('/api/v1/access/request', { method: 'POST' }) + if (response.ok) { + await refreshAccess() + setMessage('Access request submitted.') + } else { + setMessage('Access request could not be submitted.') + } + } + + async function review(subject: string, decision: 'approve' | 'reject') { + const response = await accessFetch(`/api/v1/access/requests/${encodeURIComponent(subject)}/${decision}`, { method: 'POST' }) + if (response.ok) await refreshRequests() + } + + const accessLabel = loading + ? 'Checking account' + : !authenticated + ? 'Not signed in' + : access?.status === 'approved' + ? access.role ? `Approved · ${access.role}` : 'Approved' + : access?.status === 'pending' + ? 'Pending approval' + : access?.status === 'rejected' + ? 'Access rejected' + : 'No FieldLens access' + + return ( +
+
+ +

Account & sync

Arrowstack sign-in and FieldLens access

+
+ +
+
+ {authenticated ? access?.username || access?.email || 'Signed in' : 'FieldLens account'} + {authenticated && access?.email ? access.email : 'Sign in to sync captures to Arrowstack.'} +
+ {accessLabel} +
+ + {!authenticated ? ( +
+ + +
+ ) : ( +
+ {(access?.status === 'none' || access?.status === 'rejected') && ( + + )} + +
+ )} + + {message &&

{message}

} + + {authenticated && access?.canApprove && ( +
+
+ +
Access requestsApprove operators for FieldLens
+
+ {requests.length === 0 ? ( +

No pending requests.

+ ) : requests.map((request) => ( +
+ + {request.username || request.email || request.subject} + {request.email || 'Keycloak account'} · {new Date(request.requestedAt).toLocaleString()} + +
+ + +
+
+ ))} +
+ )} +
+ ) +} diff --git a/apps/web/src/HostStatus.tsx b/apps/web/src/HostStatus.tsx index 3274922..2e6b78a 100644 --- a/apps/web/src/HostStatus.tsx +++ b/apps/web/src/HostStatus.tsx @@ -2,32 +2,20 @@ import { useLiveQuery } from 'dexie-react-hooks' import { useEffect, useState } from 'react' import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database' -import { getAccessToken, initializeAuth, keycloak, login, logout, register } from './auth' +import { getAccessToken, initializeAuth, keycloak } from './auth' import type { RegisteredDevice } from './sync' type AccessState = { status: 'none' | 'pending' | 'approved' | 'rejected' - role: string | null - canApprove: boolean - username: string | null - email: string | null } -type AccessRequest = { - subject: string - username: string | null - email: string | null - status: string - requestedAt: string -} - -async function accessFetch(path: string, init: RequestInit = {}) { +async function accessFetch(path: string) { const token = await getAccessToken() - if (!token) throw new Error('Authentication unavailable') - const headers = new Headers(init.headers) - headers.set('Authorization', `Bearer ${token}`) - if (init.body) headers.set('Content-Type', 'application/json') - return fetch(path, { ...init, headers }) + if (!token) return null + const response = await fetch(path, { + headers: { Authorization: `Bearer ${token}` }, + }) + return response.ok ? response : null } export function HostStatus() { @@ -36,40 +24,15 @@ export function HostStatus() { const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated)) const [station, setStation] = useState('This device') const [access, setAccess] = useState(null) - const [requests, setRequests] = useState([]) - const [expanded, setExpanded] = useState(false) - const [showRequests, setShowRequests] = useState(false) - - async function refreshAccess() { - const response = await accessFetch('/api/v1/access/me') - if (!response.ok) return - const next = await response.json() as AccessState - setAccess(next) - if (next.canApprove && showRequests) await refreshRequests() - } - - async function refreshRequests() { - const response = await accessFetch('/api/v1/access/requests') - if (!response.ok) return - const body = await response.json() as { requests: AccessRequest[] } - setRequests(body.requests) - } - - async function submitRequest() { - const response = await accessFetch('/api/v1/access/request', { method: 'POST' }) - if (response.ok) await refreshAccess() - } - - async function review(subject: string, decision: 'approve' | 'reject') { - const response = await accessFetch(`/api/v1/access/requests/${encodeURIComponent(subject)}/${decision}`, { method: 'POST' }) - if (response.ok) await refreshRequests() - } useEffect(() => { - void initializeAuth().then((ok) => { + void initializeAuth().then(async (ok) => { setAuthenticated(ok) - if (ok) void refreshAccess() + if (!ok) return + const response = await accessFetch('/api/v1/access/me') + if (response) setAccess(await response.json() as AccessState) }) + const refreshStation = () => { void getLocalMetadata('registeredDevice').then((device) => { if (device?.station?.name) setStation(device.station.name) @@ -80,22 +43,12 @@ export function HostStatus() { return () => window.clearInterval(timer) }, []) - async function toggleRequests() { - const next = !showRequests - setShowRequests(next) - if (next) await refreshRequests() - } - const summary = !authenticated - ? 'Sign in to sync' + ? 'Local only' : !access - ? 'Checking access' + ? 'Checking sync' : access.status !== 'approved' - ? access.status === 'pending' - ? 'Access pending' - : access.status === 'rejected' - ? 'Access rejected' - : 'Access required' + ? access.status === 'pending' ? 'Access pending' : 'Sync unavailable' : errors ? `${errors} retrying` : pending @@ -111,62 +64,14 @@ export function HostStatus() { : 'synced' return ( - <> -