The display name was always meant to be editable and the slug frozen;
until now neither had a way in. PATCH /api/sites/{site} takes a name
and a timezone (manager and above), DELETE removes an empty shop
(owner). The shop drawer in head office gets both, with the short name
shown read-only and the reason beside it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
403 lines
16 KiB
JavaScript
403 lines
16 KiB
JavaScript
// The cloud API, as this browser sees it.
|
|
//
|
|
// Everything the platform does goes through `send` below, so there is exactly
|
|
// one place that knows how a session is carried, refreshed and lost.
|
|
|
|
const ACCESS = 'bv.access'
|
|
const REFRESH = 'bv.refresh'
|
|
|
|
// localStorage, not a cookie. The API authenticates with a bearer token and
|
|
// sets no cookie of its own, so there is no CSRF surface to defend - and a
|
|
// head-office user who closes the tab expects to still be signed in tomorrow.
|
|
function read(k) { try { return localStorage.getItem(k) || '' } catch { return '' } }
|
|
function write(k, v) { try { v ? localStorage.setItem(k, v) : localStorage.removeItem(k) } catch { /* private mode */ } }
|
|
|
|
export function tokens() { return { access: read(ACCESS), refresh: read(REFRESH) } }
|
|
export function setTokens(access, refresh) { write(ACCESS, access); write(REFRESH, refresh) }
|
|
export function clearTokens() { setTokens('', '') }
|
|
export function signedIn() { return !!read(REFRESH) }
|
|
|
|
export class ApiError extends Error {
|
|
constructor(status, code, message) {
|
|
super(message)
|
|
this.status = status
|
|
// The server's own code, kept alongside its prose. A caller that has to
|
|
// match on English to tell a normal absence from a fault will get it wrong
|
|
// the first time the wording is improved.
|
|
this.code = code
|
|
}
|
|
}
|
|
|
|
// One refresh at a time, ever.
|
|
//
|
|
// The refresh token is single-use and rotates. Four screens polling at once
|
|
// would each spend it and three would lose, logging the user out at random -
|
|
// so every caller that hits an expired token waits on the same promise.
|
|
let refreshing = null
|
|
|
|
async function refresh() {
|
|
if (refreshing) return refreshing
|
|
refreshing = (async () => {
|
|
const { refresh: rt } = tokens()
|
|
if (!rt) throw new ApiError(401, 'unauthorized', 'Signed out.')
|
|
const res = await fetch('/api/auth/refresh', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ refresh_token: rt }),
|
|
})
|
|
if (!res.ok) {
|
|
clearTokens()
|
|
throw new ApiError(res.status, 'unauthorized', 'Your session has ended. Sign in again.')
|
|
}
|
|
const s = await res.json()
|
|
// Persisted BEFORE anything else runs. A tab that refreshes and is then
|
|
// closed must not come back holding a token the server already retired.
|
|
setTokens(s.access_token, s.refresh_token)
|
|
return s
|
|
})().finally(() => { refreshing = null })
|
|
return refreshing
|
|
}
|
|
|
|
async function send(method, path, body, retry = true) {
|
|
const { access } = tokens()
|
|
const headers = {}
|
|
if (access) headers.Authorization = 'Bearer ' + access
|
|
if (body !== undefined) headers['Content-Type'] = 'application/json'
|
|
|
|
const res = await fetch(path, {
|
|
method,
|
|
headers,
|
|
// Serialised up front: a retry has to send the same body again, and a
|
|
// stream would be spent after the first attempt.
|
|
body: body === undefined ? undefined : JSON.stringify(body),
|
|
})
|
|
|
|
if (res.status === 204) return null
|
|
const text = await res.text()
|
|
let parsed = null
|
|
try { parsed = text ? JSON.parse(text) : null } catch { /* not json */ }
|
|
|
|
if (res.ok) return parsed
|
|
|
|
const code = parsed?.error || ''
|
|
if (code === 'token_expired' && retry) {
|
|
// Silent. A shop assistant should not be thrown back to a login form twice
|
|
// a day because an access token reached twelve hours old.
|
|
await refresh()
|
|
return send(method, path, body, false)
|
|
}
|
|
if (res.status === 401) clearTokens()
|
|
throw new ApiError(res.status, code,
|
|
parsed?.message || `Something went wrong (${res.status}).`)
|
|
}
|
|
|
|
// fetchImage loads a picture this server holds itself, with the session's
|
|
// bearer token, and returns an object URL an <img> can use.
|
|
//
|
|
// It exists because an <img src> cannot carry an Authorization header. The
|
|
// object-storage path returns a presigned absolute URL that needs no auth,
|
|
// which is why it worked with a plain src; a picture served from our own
|
|
// database has no such link, and minting an unauthenticated one so that <img>
|
|
// could use it would add a way to reach a photograph of somebody's shop floor
|
|
// without a session - the opposite of what this path is for.
|
|
//
|
|
// The caller MUST revoke the returned URL when it is finished with it, or the
|
|
// browser keeps every blob it has ever loaded for the life of the page.
|
|
async function fetchImage(path, retry = true) {
|
|
const { access } = tokens()
|
|
const res = await fetch(path, {
|
|
headers: access ? { Authorization: 'Bearer ' + access } : {},
|
|
})
|
|
if (res.ok) return URL.createObjectURL(await res.blob())
|
|
|
|
let parsed = null
|
|
try { parsed = await res.json() } catch { /* an image endpoint may not answer json */ }
|
|
const code = parsed?.error || ''
|
|
if (code === 'token_expired' && retry) {
|
|
await refresh()
|
|
return fetchImage(path, false)
|
|
}
|
|
if (res.status === 401) clearTokens()
|
|
throw new ApiError(res.status, code,
|
|
parsed?.message || `That picture could not be loaded (${res.status}).`)
|
|
}
|
|
|
|
// A label for the session list, so somebody can tell which device to sign out.
|
|
// Deliberately coarse and never an identifier: a fingerprint here would be a
|
|
// tracking signal we have no reason to hold, and the question this answers is
|
|
// only "which of these is the one in my hand".
|
|
function deviceName() {
|
|
const ua = navigator.userAgent || ''
|
|
const os = /Windows/.test(ua) ? 'Windows'
|
|
: /Mac OS X|Macintosh/.test(ua) ? 'Mac'
|
|
: /Android/.test(ua) ? 'Android'
|
|
: /iPhone|iPad/.test(ua) ? 'iOS' : 'Unknown'
|
|
const browser = /Edg\//.test(ua) ? 'Edge'
|
|
: /Chrome\//.test(ua) ? 'Chrome'
|
|
: /Safari\//.test(ua) ? 'Safari'
|
|
: /Firefox\//.test(ua) ? 'Firefox' : 'browser'
|
|
return `${browser} on ${os}`
|
|
}
|
|
|
|
const qs = (params) => {
|
|
const p = new URLSearchParams()
|
|
for (const [k, v] of Object.entries(params || {})) {
|
|
if (v !== undefined && v !== null && v !== '') p.set(k, v)
|
|
}
|
|
const s = p.toString()
|
|
return s ? '?' + s : ''
|
|
}
|
|
|
|
export const api = {
|
|
async login(email, password) {
|
|
const res = await fetch('/api/auth/login', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ email, password, device: deviceName() }),
|
|
})
|
|
const body = await res.json().catch(() => null)
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, body?.error || '',
|
|
body?.message || 'Could not sign in.')
|
|
}
|
|
setTokens(body.access_token, body.refresh_token)
|
|
return body.user
|
|
},
|
|
|
|
// What a code says it is for, before anybody is asked to choose a password.
|
|
// Unauthenticated by necessity: the holder has no account yet.
|
|
async previewInvitation(code) {
|
|
const res = await fetch('/api/auth/invitation' + qs({ code }))
|
|
const body = await res.json().catch(() => null)
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, body?.error || '',
|
|
body?.message || 'That invitation code is not valid.')
|
|
}
|
|
return body
|
|
},
|
|
|
|
// Redeem an invitation. Returns a signed-in session, not just an account:
|
|
// sending somebody who has just chosen a password to a sign-in form to type
|
|
// it again is the sort of thing that gets blamed on the password.
|
|
//
|
|
// The address and the role are NOT sent - they come from the invitation, and
|
|
// the server refuses a body that names either.
|
|
async register({ code, full_name, password }) {
|
|
const res = await fetch('/api/auth/register', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ code, full_name, password, device: deviceName() }),
|
|
})
|
|
const body = await res.json().catch(() => null)
|
|
if (!res.ok) {
|
|
throw new ApiError(res.status, body?.error || '',
|
|
body?.message || 'Could not create the account.')
|
|
}
|
|
setTokens(body.access_token, body.refresh_token)
|
|
return body.user
|
|
},
|
|
|
|
async logout() {
|
|
try { await send('POST', '/api/auth/logout') } catch { /* already gone */ }
|
|
clearTokens()
|
|
},
|
|
|
|
me: () => send('GET', '/api/auth/me'),
|
|
|
|
sites: () => send('GET', '/api/sites'),
|
|
createSite: (input) => send('POST', '/api/sites', input),
|
|
updateSite: (site, input) => send('PATCH', `/api/sites/${encodeURIComponent(site)}`, input),
|
|
deleteSite: (site) => send('DELETE', `/api/sites/${encodeURIComponent(site)}`),
|
|
|
|
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
|
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
|
|
|
visitors: (q, limit = 50) => send('GET', '/api/visitors' + qs({ q, limit })),
|
|
visitorHistory: (id, limit = 50) =>
|
|
send('GET', `/api/visitors/${encodeURIComponent(id)}/history` + qs({ limit })),
|
|
saveProfile: (id, profile) =>
|
|
send('PUT', `/api/visitors/${encodeURIComponent(id)}/profile`, profile),
|
|
|
|
footfall: (params) => send('GET', '/api/reports/footfall' + qs(params)),
|
|
conversion: (params) => send('GET', '/api/reports/conversion' + qs(params)),
|
|
|
|
cameras: () => send('GET', '/api/cameras'),
|
|
// A camera picture this server holds itself. Returns an object URL the
|
|
// caller must revoke; see fetchImage.
|
|
cameraSnapshot: (url) => fetchImage(url),
|
|
createCamera: (siteID, cam) =>
|
|
send('POST', `/api/sites/${encodeURIComponent(siteID)}/cameras`, cam),
|
|
updateCamera: (id, cam) => send('PATCH', `/api/cameras/${encodeURIComponent(id)}`, cam),
|
|
deleteCamera: (id) => send('DELETE', `/api/cameras/${encodeURIComponent(id)}`),
|
|
|
|
// Prove a camera works. `kind` is "connection" (can the shop PC open the
|
|
// stream) or "placement" (does someone walking past produce a usable view).
|
|
checkCamera: (id, kind, seconds) =>
|
|
send('POST', `/api/cameras/${encodeURIComponent(id)}/check`,
|
|
{ kind, ...(seconds ? { seconds } : {}) }),
|
|
siteCheck: (siteID) =>
|
|
send('GET', `/api/sites/${encodeURIComponent(siteID)}/check`),
|
|
|
|
// The one-shot code a shop PC is claimed with. Returned in full exactly once
|
|
// - only a hash is stored - so whatever calls this has to show it there and
|
|
// then, and must not expect to read it back later.
|
|
enrolmentCode: (siteID, input) =>
|
|
send('POST', `/api/sites/${encodeURIComponent(siteID)}/enrolment-code`,
|
|
input || {}),
|
|
|
|
ask: (history) => send('POST', '/api/assistant', { history }),
|
|
|
|
clients: () => send('GET', '/api/admin/clients'),
|
|
createClient: (input) => send('POST', '/api/admin/clients', input),
|
|
|
|
// The people who work here.
|
|
team: () => send('GET', '/api/team'),
|
|
updateMember: (id, changes) =>
|
|
send('PATCH', `/api/team/${encodeURIComponent(id)}`, changes),
|
|
invitations: () => send('GET', '/api/team/invitations'),
|
|
// The code comes back in full exactly once - only a hash is stored - so
|
|
// whatever calls this has to show it there and then and must not expect to
|
|
// read it back later. Same contract as enrolmentCode above.
|
|
invite: (input) => send('POST', '/api/team/invitations', input),
|
|
revokeInvitation: (id) =>
|
|
send('DELETE', `/api/team/invitations/${encodeURIComponent(id)}`),
|
|
|
|
// Devices this account is signed in on. The point of holding sessions in a
|
|
// table rather than issuing JWTs is that signing one out actually works.
|
|
sessions: () => send('GET', '/api/auth/sessions'),
|
|
revokeSession: (id) =>
|
|
send('DELETE', `/api/auth/sessions/${encodeURIComponent(id)}`),
|
|
signOutOthers: () => send('POST', '/api/auth/sessions/revoke-others'),
|
|
}
|
|
|
|
// The live stream, read with fetch rather than EventSource.
|
|
//
|
|
// EventSource cannot set headers, so using it would mean putting the session
|
|
// token in the query string - where it lands in server logs, browser history
|
|
// and any screenshot of the URL bar. Reading the stream by hand costs the
|
|
// frame parsing below and keeps the token in an Authorization header.
|
|
//
|
|
// The cursor is held HERE, not derived from what is on screen. That is what
|
|
// makes a reconnect lossless: whatever arrived while the connection was down
|
|
// is delivered on the next one.
|
|
export function streamArrivals({ cursor, siteId, onPage, onError, signal }) {
|
|
let stopped = false
|
|
let position = cursor || ''
|
|
|
|
const run = async () => {
|
|
while (!stopped) {
|
|
try {
|
|
const { access } = tokens()
|
|
const res = await fetch('/api/visits/stream' + qs({ cursor: position, site_id: siteId }), {
|
|
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
|
signal,
|
|
})
|
|
if (res.status === 401) {
|
|
await refresh()
|
|
continue
|
|
}
|
|
if (!res.ok || !res.body) throw new Error('stream unavailable')
|
|
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
while (!stopped) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
buf += decoder.decode(value, { stream: true })
|
|
// Frames are separated by a blank line. Anything after the last one
|
|
// is a partial frame and stays in the buffer.
|
|
let split
|
|
while ((split = buf.indexOf('\n\n')) !== -1) {
|
|
const frame = buf.slice(0, split)
|
|
buf = buf.slice(split + 2)
|
|
for (const line of frame.split('\n')) {
|
|
if (line.startsWith('id: ')) position = line.slice(4).trim()
|
|
else if (line.startsWith('data: ')) {
|
|
try { onPage(JSON.parse(line.slice(6))) } catch { /* partial */ }
|
|
}
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (stopped || signal?.aborted) return
|
|
onError?.(err)
|
|
}
|
|
if (stopped) return
|
|
// Reconnect. `position` survives, so nothing that arrived while we were
|
|
// away is missed - that is the whole reason the cursor is held here and
|
|
// not derived from what is on screen.
|
|
await new Promise(r => setTimeout(r, 3000))
|
|
}
|
|
}
|
|
run()
|
|
return () => { stopped = true }
|
|
}
|
|
|
|
// streamCameraLive renders one camera's live view into an <img>.
|
|
//
|
|
// Frames arrive base64 over SSE for the same reason the snapshot is fetched
|
|
// rather than linked: an <img> cannot send an Authorization header, and minting
|
|
// a URL that works without a session — for LIVE video of a shop floor — would
|
|
// be a far worse trade than the 33% base64 costs.
|
|
//
|
|
// The frame is written straight into `img.src` as a data URL rather than an
|
|
// object URL. Object URLs would have to be revoked one per frame, several times
|
|
// a second, and a single missed revoke is a leak that grows for as long as the
|
|
// view is open. A data URL is owned by the element and replaced by the next one.
|
|
export function streamCameraLive({ cameraId, img, onState, signal }) {
|
|
let stopped = false
|
|
|
|
const run = async () => {
|
|
while (!stopped) {
|
|
try {
|
|
const { access } = tokens()
|
|
const res = await fetch(`/api/cameras/${cameraId}/live`, {
|
|
headers: { Authorization: 'Bearer ' + access, Accept: 'text/event-stream' },
|
|
signal,
|
|
})
|
|
if (res.status === 401) { await refresh(); continue }
|
|
if (!res.ok || !res.body) throw new Error('live view unavailable')
|
|
|
|
const reader = res.body.getReader()
|
|
const decoder = new TextDecoder()
|
|
let buf = ''
|
|
while (!stopped) {
|
|
const { value, done } = await reader.read()
|
|
if (done) break
|
|
buf += decoder.decode(value, { stream: true })
|
|
let split
|
|
while ((split = buf.indexOf('\n\n')) !== -1) {
|
|
const chunk = buf.slice(0, split)
|
|
buf = buf.slice(split + 2)
|
|
let event = 'message', data = ''
|
|
for (const line of chunk.split('\n')) {
|
|
if (line.startsWith('event: ')) event = line.slice(7).trim()
|
|
else if (line.startsWith('data: ')) data = line.slice(6)
|
|
}
|
|
if (event === 'frame' && data) {
|
|
if (img.current) img.current.src = 'data:image/jpeg;base64,' + data
|
|
onState?.('live')
|
|
} else if (event === 'waiting') {
|
|
// The server has registered us; the shop PC has not started
|
|
// pushing yet. Saying so beats an empty box, because the wait is
|
|
// a real second or two while the agent is asked.
|
|
onState?.('waiting')
|
|
}
|
|
}
|
|
}
|
|
} catch (err) {
|
|
if (stopped || signal?.aborted) return
|
|
onState?.('reconnecting')
|
|
}
|
|
if (stopped) return
|
|
// The server caps one push so a tab left open for a week does not leave
|
|
// a shop uploading for a week. Reconnecting is how a viewer who IS still
|
|
// watching carries on, so this is a normal event, not an error.
|
|
await new Promise(r => setTimeout(r, 1500))
|
|
}
|
|
}
|
|
run()
|
|
return () => { stopped = true }
|
|
}
|