ui: move account controls into settings
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
Admin 2026-09-23 10:45:32 +00:00
parent 4a768555dc
commit eeda34bc63
6 changed files with 243 additions and 192 deletions

View File

@ -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<AccessState | null>(null)
const [requests, setRequests] = useState<AccessRequest[]>([])
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 (
<section className="settings-section account-settings">
<header>
<span className="settings-icon"><UserRound /></span>
<div><h2>Account & sync</h2><p>Arrowstack sign-in and FieldLens access</p></div>
</header>
<div className="setting-row account-summary">
<div>
<strong>{authenticated ? access?.username || access?.email || 'Signed in' : 'FieldLens account'}</strong>
<small>{authenticated && access?.email ? access.email : 'Sign in to sync captures to Arrowstack.'}</small>
</div>
<b>{accessLabel}</b>
</div>
{!authenticated ? (
<div className="account-actions">
<button className="primary-button" type="button" onClick={() => void login()}>Sign in</button>
<button className="quiet-button" type="button" onClick={() => void register()}>Create account</button>
</div>
) : (
<div className="account-actions">
{(access?.status === 'none' || access?.status === 'rejected') && (
<button className="primary-button" type="button" onClick={() => void submitRequest()}>Request access</button>
)}
<button className="quiet-button" type="button" onClick={() => void logout()}>Sign out</button>
</div>
)}
{message && <p className="account-message" role="status">{message}</p>}
{authenticated && access?.canApprove && (
<div className="access-requests">
<div className="access-requests-heading">
<ShieldCheck size={17} />
<div><strong>Access requests</strong><small>Approve operators for FieldLens</small></div>
</div>
{requests.length === 0 ? (
<p className="empty-requests">No pending requests.</p>
) : requests.map((request) => (
<article key={request.subject}>
<span>
<strong>{request.username || request.email || request.subject}</strong>
<small>{request.email || 'Keycloak account'} · {new Date(request.requestedAt).toLocaleString()}</small>
</span>
<div>
<button className="primary-button" type="button" onClick={() => void review(request.subject, 'approve')}>Approve</button>
<button className="quiet-button" type="button" onClick={() => void review(request.subject, 'reject')}>Reject</button>
</div>
</article>
))}
</div>
)}
</section>
)
}

View File

@ -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<AccessState | null>(null)
const [requests, setRequests] = useState<AccessRequest[]>([])
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>('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 (
<>
<aside className={`host-status ${expanded ? 'expanded' : ''}`} aria-label="Arrowstack synchronization status">
<button className="host-status-trigger" type="button" aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}>
<i className={`sync-dot ${tone}`} aria-hidden="true" />
<span className="host-status-copy">
<strong>{station}</strong>
<small>{summary}</small>
</span>
<span className="host-status-more" aria-hidden="true">{expanded ? '×' : '•••'}</span>
</button>
{expanded && (
<div className="host-status-menu">
{authenticated && access?.username && <p className="host-user">{access.username}{access.email ? <small>{access.email}</small> : null}</p>}
{!authenticated ? (
<div className="host-actions">
<button type="button" onClick={() => void login()}>Sign in</button>
<button type="button" className="secondary" onClick={() => void register()}>Create account</button>
</div>
) : (
<div className="host-actions">
{(access?.status === 'none' || access?.status === 'rejected') && (
<button type="button" onClick={() => void submitRequest()}>Request access</button>
)}
{access?.canApprove && (
<button type="button" className="secondary" onClick={() => void toggleRequests()}>Requests</button>
)}
<button type="button" className="secondary" onClick={() => void logout()}>Sign out</button>
</div>
)}
</div>
)}
</aside>
{showRequests && access?.canApprove && (
<section className="access-panel" aria-label="FieldLens access requests">
<header>
<strong>Access requests</strong>
<button type="button" onClick={() => setShowRequests(false)}>×</button>
</header>
{requests.length === 0 ? (
<p>No pending requests.</p>
) : requests.map((request) => (
<article key={request.subject}>
<span>
<strong>{request.username || request.email || request.subject}</strong>
<small>{request.email || 'Keycloak account'} · {new Date(request.requestedAt).toLocaleString()}</small>
</span>
<div>
<button type="button" onClick={() => void review(request.subject, 'approve')}>Approve</button>
<button type="button" className="secondary" onClick={() => void review(request.subject, 'reject')}>Reject</button>
</div>
</article>
))}
</section>
)}
</>
<aside className="host-status" aria-label="Arrowstack synchronization status">
<div className="host-status-indicator">
<i className={`sync-dot ${tone}`} aria-hidden="true" />
<span className="host-status-copy">
<strong>{station}</strong>
<small>{summary}</small>
</span>
</div>
</aside>
)
}

View File

@ -3,24 +3,22 @@
z-index: 80;
top: calc(78px + env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
width: min(238px, calc(100vw - 36px));
width: min(210px, calc(100vw - 36px));
font: 11px/1.25 system-ui, sans-serif;
}
.host-status-trigger {
width: 100%;
.host-status-indicator {
display: grid;
grid-template-columns: 9px minmax(0, 1fr) auto;
grid-template-columns: 9px minmax(0, 1fr);
align-items: center;
gap: 9px;
padding: 8px 9px 8px 11px;
padding: 8px 11px;
border: 1px solid rgba(255,255,255,.16);
border-radius: 8px;
color: white;
background: rgba(23,33,31,.88);
box-shadow: 0 5px 18px rgba(0,0,0,.18);
background: rgba(23,33,31,.82);
box-shadow: 0 5px 18px rgba(0,0,0,.16);
backdrop-filter: blur(12px);
text-align: left;
}
.sync-dot {
@ -39,74 +37,67 @@
.host-status-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.host-status-copy strong { font-size: 11px; }
.host-status-copy small { color: #aebbb6; font-size: 9px; }
.host-status-more { color: #aebbb6; font-size: 12px; letter-spacing: 1px; }
.host-status-menu {
margin-top: 6px;
padding: 9px;
border: 1px solid #314039;
border-radius: 8px;
color: white;
background: rgba(23,33,31,.97);
box-shadow: 0 10px 30px rgba(0,0,0,.28);
backdrop-filter: blur(14px);
.account-settings { grid-column: 1 / -1; }
.account-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
padding: 16px 18px;
border-top: 1px solid var(--line);
}
.host-user { margin: 0 0 8px; padding: 0 2px 8px; border-bottom: 1px solid #314039; font-weight: 800; }
.host-user small { display: block; margin-top: 2px; color: #aebbb6; font-size: 9px; font-weight: 500; overflow-wrap: anywhere; }
.host-actions { display: flex; flex-wrap: wrap; gap: 6px; }
.host-actions button,
.access-panel button {
min-height: 31px;
border: 0;
border-radius: 6px;
padding: 0 9px;
background: #f4d35e;
color: #17211f;
font-size: 9px;
font-weight: 900;
.account-actions .primary-button,
.account-actions .quiet-button { min-width: 120px; }
.account-message {
margin: 0;
padding: 0 18px 16px;
color: var(--green);
font-size: 10px;
font-weight: 800;
}
.host-actions button.secondary,
.access-panel button.secondary { background: #314039; color: white; }
.access-panel {
position: fixed;
z-index: 81;
top: calc(170px + env(safe-area-inset-top));
right: max(18px, env(safe-area-inset-right));
width: min(430px, calc(100vw - 36px));
max-height: 65vh;
overflow: auto;
padding: 12px;
border: 1px solid #314039;
border-radius: 10px;
background: rgba(23,33,31,.98);
color: white;
box-shadow: 0 12px 36px rgba(0,0,0,.38);
font: 12px/1.35 system-ui, sans-serif;
.access-requests { border-top: 1px solid var(--line); }
.access-requests-heading {
display: flex;
align-items: center;
gap: 10px;
padding: 15px 18px;
background: #fafaf6;
border-bottom: 1px solid var(--line);
}
.access-panel header,
.access-panel article { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.access-panel header { padding-bottom: 10px; border-bottom: 1px solid #314039; }
.access-panel header button { min-width: 30px; font-size: 18px; line-height: 1; }
.access-panel article { padding: 11px 0; border-bottom: 1px solid #26332e; }
.access-panel article:last-child { border-bottom: 0; }
.access-panel article span { display: grid; gap: 3px; min-width: 0; }
.access-panel article small { color: #aebbb6; overflow-wrap: anywhere; }
.access-panel article div { display: flex; gap: 6px; flex-shrink: 0; }
.access-panel p { color: #aebbb6; }
.access-requests-heading svg { color: var(--green); }
.access-requests-heading strong,
.access-requests-heading small { display: block; }
.access-requests-heading strong { font-size: 11px; }
.access-requests-heading small { margin-top: 2px; color: #75807b; font-size: 9px; }
.access-requests article {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
align-items: center;
gap: 16px;
padding: 13px 18px;
border-bottom: 1px solid var(--line);
}
.access-requests article:last-child { border-bottom: 0; }
.access-requests article > span { min-width: 0; }
.access-requests article strong,
.access-requests article small { display: block; }
.access-requests article strong { font-size: 11px; }
.access-requests article small { margin-top: 3px; color: #75807b; font-size: 9px; overflow-wrap: anywhere; }
.access-requests article > div { display: flex; gap: 7px; }
.access-requests .primary-button,
.access-requests .quiet-button { min-height: 34px; padding: 0 10px; }
.empty-requests { margin: 0; padding: 16px 18px; color: #75807b; font-size: 10px; }
@media (max-width: 760px) {
.host-status {
top: calc(86px + env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
width: min(218px, calc(100vw - 24px));
}
.host-status-trigger { padding: 7px 8px 7px 10px; }
.host-status-menu { padding: 8px; }
.access-panel {
top: calc(184px + env(safe-area-inset-top));
right: max(12px, env(safe-area-inset-right));
width: calc(100vw - 24px);
width: min(188px, calc(100vw - 24px));
}
.host-status-indicator { padding: 7px 10px; }
.account-actions { display: grid; grid-template-columns: 1fr 1fr; }
.account-actions .primary-button,
.account-actions .quiet-button { width: 100%; min-width: 0; }
.access-requests article { grid-template-columns: 1fr; }
.access-requests article > div { display: grid; grid-template-columns: 1fr 1fr; }
}

View File

@ -10,6 +10,7 @@ import './host.css'
import CoreApp from '../../../packages/fieldlens-core/src/App'
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
import { AccountSettings } from './AccountSettings'
import { HostStatus } from './HostStatus'
import { registerArrowstackReferenceProvider } from './referenceProvider'
import { installSyncTriggers } from './sync'
@ -20,7 +21,7 @@ installSyncTriggers()
createRoot(document.getElementById('root')!).render(
<StrictMode>
<CoreApp />
<CoreApp settingsAddon={<AccountSettings />} />
<HostStatus />
</StrictMode>,
)

View File

@ -1,6 +1,6 @@
import { useLiveQuery } from 'dexie-react-hooks'
import { CloudDownload, FlaskConical, History, LoaderCircle, ScanLine, Settings, Share, Smartphone, SquarePlus, Wifi, WifiOff, X } from 'lucide-react'
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from 'react'
import { lazy, Suspense, useCallback, useEffect, useRef, useState, type ReactNode } from 'react'
import { useRegisterSW } from 'virtual:pwa-register/react'
import './App.css'
@ -28,7 +28,7 @@ const navigation: NavigationItem[] = [
{ id: 'settings', label: 'Settings', icon: Settings },
]
function App() {
function App({ settingsAddon }: { settingsAddon?: ReactNode }) {
const [view, setView] = useState<View>('scanner')
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null)
const [showIosInstall, setShowIosInstall] = useState(false)
@ -195,6 +195,7 @@ function App() {
isUpdating={isUpdating}
updateError={updateError}
onApplyUpdate={applyUpdate}
hostSection={settingsAddon}
/>
)}
</Suspense>

View File

@ -1,5 +1,5 @@
import { CloudDownload, Database, RefreshCcw, ScanLine, ShieldCheck } from 'lucide-react'
import { useEffect, useState } from 'react'
import { useEffect, useState, type ReactNode } from 'react'
import { scanFormatOptions } from '../../lib/barcodes'
import { DEVICE_HOLD_EVENT, getDeviceHoldOrientation, type DeviceHoldOrientation } from '../../lib/deviceOrientation'
@ -14,9 +14,10 @@ type SettingsViewProps = {
isUpdating: boolean
updateError: string
onApplyUpdate: () => Promise<void>
hostSection?: ReactNode
}
export function SettingsView({ storageStatus, updateAvailable, isUpdating, updateError, onApplyUpdate }: SettingsViewProps) {
export function SettingsView({ storageStatus, updateAvailable, isUpdating, updateError, onApplyUpdate, hostSection }: SettingsViewProps) {
const [preferences, setPreferences] = useState(loadScannerPreferences)
const [deviceHold, setDeviceHold] = useState<DeviceHoldOrientation>(getDeviceHoldOrientation)
@ -82,6 +83,8 @@ export function SettingsView({ storageStatus, updateAvailable, isUpdating, updat
<div className="setting-row"><div><strong>Remote integration</strong><small>Register a provider or add sync in the host application</small></div><b>Host supplied</b></div>
</section>
{hostSection}
<section className="settings-section">
<header><span className="settings-icon"><CloudDownload /></span><div><h2>Application</h2><p>PWA lifecycle</p></div></header>
<div className="setting-row"><div><strong>{updateAvailable ? 'Update available' : 'Service worker active'}</strong><small>{updateError || (isUpdating ? 'Installing the latest build…' : updateAvailable ? 'A newer build is ready.' : 'Updates are managed by the PWA service worker.')}</small></div><button className="quiet-button" type="button" disabled={!updateAvailable || isUpdating} onClick={() => void onApplyUpdate()}><CloudDownload size={15} /> {isUpdating ? 'Updating' : 'Update'}</button></div>