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 { useEffect, useState } from 'react'
|
||||||
|
|
||||||
import { database, getLocalMetadata, type OutboxRecord } from '../../../packages/fieldlens-core/src/lib/database'
|
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'
|
import type { RegisteredDevice } from './sync'
|
||||||
|
|
||||||
type AccessState = {
|
type AccessState = {
|
||||||
status: 'none' | 'pending' | 'approved' | 'rejected'
|
status: 'none' | 'pending' | 'approved' | 'rejected'
|
||||||
role: string | null
|
|
||||||
canApprove: boolean
|
|
||||||
username: string | null
|
|
||||||
email: string | null
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type AccessRequest = {
|
async function accessFetch(path: string) {
|
||||||
subject: string
|
|
||||||
username: string | null
|
|
||||||
email: string | null
|
|
||||||
status: string
|
|
||||||
requestedAt: string
|
|
||||||
}
|
|
||||||
|
|
||||||
async function accessFetch(path: string, init: RequestInit = {}) {
|
|
||||||
const token = await getAccessToken()
|
const token = await getAccessToken()
|
||||||
if (!token) throw new Error('Authentication unavailable')
|
if (!token) return null
|
||||||
const headers = new Headers(init.headers)
|
const response = await fetch(path, {
|
||||||
headers.set('Authorization', `Bearer ${token}`)
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
if (init.body) headers.set('Content-Type', 'application/json')
|
})
|
||||||
return fetch(path, { ...init, headers })
|
return response.ok ? response : null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function HostStatus() {
|
export function HostStatus() {
|
||||||
@ -36,40 +24,15 @@ export function HostStatus() {
|
|||||||
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
|
const [authenticated, setAuthenticated] = useState(Boolean(keycloak?.authenticated))
|
||||||
const [station, setStation] = useState('This device')
|
const [station, setStation] = useState('This device')
|
||||||
const [access, setAccess] = useState<AccessState | null>(null)
|
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(() => {
|
useEffect(() => {
|
||||||
void initializeAuth().then((ok) => {
|
void initializeAuth().then(async (ok) => {
|
||||||
setAuthenticated(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 = () => {
|
const refreshStation = () => {
|
||||||
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
|
void getLocalMetadata<RegisteredDevice>('registeredDevice').then((device) => {
|
||||||
if (device?.station?.name) setStation(device.station.name)
|
if (device?.station?.name) setStation(device.station.name)
|
||||||
@ -80,22 +43,12 @@ export function HostStatus() {
|
|||||||
return () => window.clearInterval(timer)
|
return () => window.clearInterval(timer)
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
async function toggleRequests() {
|
|
||||||
const next = !showRequests
|
|
||||||
setShowRequests(next)
|
|
||||||
if (next) await refreshRequests()
|
|
||||||
}
|
|
||||||
|
|
||||||
const summary = !authenticated
|
const summary = !authenticated
|
||||||
? 'Sign in to sync'
|
? 'Local only'
|
||||||
: !access
|
: !access
|
||||||
? 'Checking access'
|
? 'Checking sync'
|
||||||
: access.status !== 'approved'
|
: access.status !== 'approved'
|
||||||
? access.status === 'pending'
|
? access.status === 'pending' ? 'Access pending' : 'Sync unavailable'
|
||||||
? 'Access pending'
|
|
||||||
: access.status === 'rejected'
|
|
||||||
? 'Access rejected'
|
|
||||||
: 'Access required'
|
|
||||||
: errors
|
: errors
|
||||||
? `${errors} retrying`
|
? `${errors} retrying`
|
||||||
: pending
|
: pending
|
||||||
@ -111,62 +64,14 @@ export function HostStatus() {
|
|||||||
: 'synced'
|
: 'synced'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<aside className="host-status" aria-label="Arrowstack synchronization status">
|
||||||
<aside className={`host-status ${expanded ? 'expanded' : ''}`} aria-label="Arrowstack synchronization status">
|
<div className="host-status-indicator">
|
||||||
<button className="host-status-trigger" type="button" aria-expanded={expanded} onClick={() => setExpanded((value) => !value)}>
|
<i className={`sync-dot ${tone}`} aria-hidden="true" />
|
||||||
<i className={`sync-dot ${tone}`} aria-hidden="true" />
|
<span className="host-status-copy">
|
||||||
<span className="host-status-copy">
|
<strong>{station}</strong>
|
||||||
<strong>{station}</strong>
|
<small>{summary}</small>
|
||||||
<small>{summary}</small>
|
</span>
|
||||||
</span>
|
</div>
|
||||||
<span className="host-status-more" aria-hidden="true">{expanded ? '×' : '•••'}</span>
|
</aside>
|
||||||
</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;
|
z-index: 80;
|
||||||
top: calc(78px + env(safe-area-inset-top));
|
top: calc(78px + env(safe-area-inset-top));
|
||||||
right: max(18px, env(safe-area-inset-right));
|
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;
|
font: 11px/1.25 system-ui, sans-serif;
|
||||||
}
|
}
|
||||||
|
|
||||||
.host-status-trigger {
|
.host-status-indicator {
|
||||||
width: 100%;
|
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 9px minmax(0, 1fr) auto;
|
grid-template-columns: 9px minmax(0, 1fr);
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
padding: 8px 9px 8px 11px;
|
padding: 8px 11px;
|
||||||
border: 1px solid rgba(255,255,255,.16);
|
border: 1px solid rgba(255,255,255,.16);
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
color: white;
|
color: white;
|
||||||
background: rgba(23,33,31,.88);
|
background: rgba(23,33,31,.82);
|
||||||
box-shadow: 0 5px 18px rgba(0,0,0,.18);
|
box-shadow: 0 5px 18px rgba(0,0,0,.16);
|
||||||
backdrop-filter: blur(12px);
|
backdrop-filter: blur(12px);
|
||||||
text-align: left;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.sync-dot {
|
.sync-dot {
|
||||||
@ -39,74 +37,67 @@
|
|||||||
.host-status-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
.host-status-copy small { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||||
.host-status-copy strong { font-size: 11px; }
|
.host-status-copy strong { font-size: 11px; }
|
||||||
.host-status-copy small { color: #aebbb6; font-size: 9px; }
|
.host-status-copy small { color: #aebbb6; font-size: 9px; }
|
||||||
.host-status-more { color: #aebbb6; font-size: 12px; letter-spacing: 1px; }
|
|
||||||
|
|
||||||
.host-status-menu {
|
.account-settings { grid-column: 1 / -1; }
|
||||||
margin-top: 6px;
|
.account-actions {
|
||||||
padding: 9px;
|
display: flex;
|
||||||
border: 1px solid #314039;
|
flex-wrap: wrap;
|
||||||
border-radius: 8px;
|
gap: 8px;
|
||||||
color: white;
|
padding: 16px 18px;
|
||||||
background: rgba(23,33,31,.97);
|
border-top: 1px solid var(--line);
|
||||||
box-shadow: 0 10px 30px rgba(0,0,0,.28);
|
|
||||||
backdrop-filter: blur(14px);
|
|
||||||
}
|
}
|
||||||
|
.account-actions .primary-button,
|
||||||
.host-user { margin: 0 0 8px; padding: 0 2px 8px; border-bottom: 1px solid #314039; font-weight: 800; }
|
.account-actions .quiet-button { min-width: 120px; }
|
||||||
.host-user small { display: block; margin-top: 2px; color: #aebbb6; font-size: 9px; font-weight: 500; overflow-wrap: anywhere; }
|
.account-message {
|
||||||
.host-actions { display: flex; flex-wrap: wrap; gap: 6px; }
|
margin: 0;
|
||||||
.host-actions button,
|
padding: 0 18px 16px;
|
||||||
.access-panel button {
|
color: var(--green);
|
||||||
min-height: 31px;
|
font-size: 10px;
|
||||||
border: 0;
|
font-weight: 800;
|
||||||
border-radius: 6px;
|
|
||||||
padding: 0 9px;
|
|
||||||
background: #f4d35e;
|
|
||||||
color: #17211f;
|
|
||||||
font-size: 9px;
|
|
||||||
font-weight: 900;
|
|
||||||
}
|
}
|
||||||
.host-actions button.secondary,
|
.access-requests { border-top: 1px solid var(--line); }
|
||||||
.access-panel button.secondary { background: #314039; color: white; }
|
.access-requests-heading {
|
||||||
|
display: flex;
|
||||||
.access-panel {
|
align-items: center;
|
||||||
position: fixed;
|
gap: 10px;
|
||||||
z-index: 81;
|
padding: 15px 18px;
|
||||||
top: calc(170px + env(safe-area-inset-top));
|
background: #fafaf6;
|
||||||
right: max(18px, env(safe-area-inset-right));
|
border-bottom: 1px solid var(--line);
|
||||||
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-panel header,
|
.access-requests-heading svg { color: var(--green); }
|
||||||
.access-panel article { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
|
.access-requests-heading strong,
|
||||||
.access-panel header { padding-bottom: 10px; border-bottom: 1px solid #314039; }
|
.access-requests-heading small { display: block; }
|
||||||
.access-panel header button { min-width: 30px; font-size: 18px; line-height: 1; }
|
.access-requests-heading strong { font-size: 11px; }
|
||||||
.access-panel article { padding: 11px 0; border-bottom: 1px solid #26332e; }
|
.access-requests-heading small { margin-top: 2px; color: #75807b; font-size: 9px; }
|
||||||
.access-panel article:last-child { border-bottom: 0; }
|
.access-requests article {
|
||||||
.access-panel article span { display: grid; gap: 3px; min-width: 0; }
|
display: grid;
|
||||||
.access-panel article small { color: #aebbb6; overflow-wrap: anywhere; }
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
.access-panel article div { display: flex; gap: 6px; flex-shrink: 0; }
|
align-items: center;
|
||||||
.access-panel p { color: #aebbb6; }
|
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) {
|
@media (max-width: 760px) {
|
||||||
.host-status {
|
.host-status {
|
||||||
top: calc(86px + env(safe-area-inset-top));
|
top: calc(86px + env(safe-area-inset-top));
|
||||||
right: max(12px, env(safe-area-inset-right));
|
right: max(12px, env(safe-area-inset-right));
|
||||||
width: min(218px, calc(100vw - 24px));
|
width: min(188px, 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);
|
|
||||||
}
|
}
|
||||||
|
.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 CoreApp from '../../../packages/fieldlens-core/src/App'
|
||||||
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
|
import { startDeviceHoldTracking } from '../../../packages/fieldlens-core/src/lib/deviceOrientation'
|
||||||
|
import { AccountSettings } from './AccountSettings'
|
||||||
import { HostStatus } from './HostStatus'
|
import { HostStatus } from './HostStatus'
|
||||||
import { registerArrowstackReferenceProvider } from './referenceProvider'
|
import { registerArrowstackReferenceProvider } from './referenceProvider'
|
||||||
import { installSyncTriggers } from './sync'
|
import { installSyncTriggers } from './sync'
|
||||||
@ -20,7 +21,7 @@ installSyncTriggers()
|
|||||||
|
|
||||||
createRoot(document.getElementById('root')!).render(
|
createRoot(document.getElementById('root')!).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<CoreApp />
|
<CoreApp settingsAddon={<AccountSettings />} />
|
||||||
<HostStatus />
|
<HostStatus />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useLiveQuery } from 'dexie-react-hooks'
|
import { useLiveQuery } from 'dexie-react-hooks'
|
||||||
import { CloudDownload, FlaskConical, History, LoaderCircle, ScanLine, Settings, Share, Smartphone, SquarePlus, Wifi, WifiOff, X } from 'lucide-react'
|
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 { useRegisterSW } from 'virtual:pwa-register/react'
|
||||||
|
|
||||||
import './App.css'
|
import './App.css'
|
||||||
@ -28,7 +28,7 @@ const navigation: NavigationItem[] = [
|
|||||||
{ id: 'settings', label: 'Settings', icon: Settings },
|
{ id: 'settings', label: 'Settings', icon: Settings },
|
||||||
]
|
]
|
||||||
|
|
||||||
function App() {
|
function App({ settingsAddon }: { settingsAddon?: ReactNode }) {
|
||||||
const [view, setView] = useState<View>('scanner')
|
const [view, setView] = useState<View>('scanner')
|
||||||
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
const [installPrompt, setInstallPrompt] = useState<BeforeInstallPromptEvent | null>(null)
|
||||||
const [showIosInstall, setShowIosInstall] = useState(false)
|
const [showIosInstall, setShowIosInstall] = useState(false)
|
||||||
@ -195,6 +195,7 @@ function App() {
|
|||||||
isUpdating={isUpdating}
|
isUpdating={isUpdating}
|
||||||
updateError={updateError}
|
updateError={updateError}
|
||||||
onApplyUpdate={applyUpdate}
|
onApplyUpdate={applyUpdate}
|
||||||
|
hostSection={settingsAddon}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
import { CloudDownload, Database, RefreshCcw, ScanLine, ShieldCheck } from 'lucide-react'
|
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 { scanFormatOptions } from '../../lib/barcodes'
|
||||||
import { DEVICE_HOLD_EVENT, getDeviceHoldOrientation, type DeviceHoldOrientation } from '../../lib/deviceOrientation'
|
import { DEVICE_HOLD_EVENT, getDeviceHoldOrientation, type DeviceHoldOrientation } from '../../lib/deviceOrientation'
|
||||||
@ -14,9 +14,10 @@ type SettingsViewProps = {
|
|||||||
isUpdating: boolean
|
isUpdating: boolean
|
||||||
updateError: string
|
updateError: string
|
||||||
onApplyUpdate: () => Promise<void>
|
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 [preferences, setPreferences] = useState(loadScannerPreferences)
|
||||||
const [deviceHold, setDeviceHold] = useState<DeviceHoldOrientation>(getDeviceHoldOrientation)
|
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>
|
<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>
|
</section>
|
||||||
|
|
||||||
|
{hostSection}
|
||||||
|
|
||||||
<section className="settings-section">
|
<section className="settings-section">
|
||||||
<header><span className="settings-icon"><CloudDownload /></span><div><h2>Application</h2><p>PWA lifecycle</p></div></header>
|
<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>
|
<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