This commit is contained in:
parent
4a768555dc
commit
eeda34bc63
150
apps/web/src/AccountSettings.tsx
Normal file
150
apps/web/src/AccountSettings.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@ -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)}>
|
||||
<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>
|
||||
<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>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@ -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; }
|
||||
}
|
||||
|
||||
@ -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>,
|
||||
)
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user