Opening a shop is an API call; the broker learns of it in the same request
The last step of onboarding that needed a shell: provision site printed a broker password and a person typed it into Mosquitto's passwd file on the host - mounted read-only in the container, so the first attempt failed silently and the password was re-rolled. No tenant could open a second branch without us. The server now drives Mosquitto's dynamic-security plugin over its own broker login: POST /api/sites (owner) writes the row and the sealed password, registers the login and a per-site role with literal topics (the 2.0 plugin does not substitute %u - measured), and removes the row again if the broker refuses, so a shop cannot exist in the database and not on the broker. provision site goes through the same path. The head-office Shops screen gets 'Open a new shop'. broker-init converts the existing passwd file into the plugin's store with every hash intact - PBKDF2-SHA512 both sides - so the cutover re-claims no shop PC. Rehearsed locally: old logins keep working, isolation holds, the health probe works, and a PC claiming a shop opened through the API connects as that shop. run-local.sh now brings the broker up the same way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
@@ -205,6 +205,7 @@ export const api = {
|
||||
me: () => send('GET', '/api/auth/me'),
|
||||
|
||||
sites: () => send('GET', '/api/sites'),
|
||||
createSite: (input) => send('POST', '/api/sites', input),
|
||||
|
||||
// The live arrivals feed. `cursor` is opaque and must be echoed back.
|
||||
arrivals: (params) => send('GET', '/api/visits' + qs(params)),
|
||||
|
||||
@@ -17,18 +17,22 @@ import SiteCheck from './SiteCheck.jsx'
|
||||
// shops. So the shop's own camera view is the card, the numbers sit under it,
|
||||
// and one line says what to do — with the technical detail one click away
|
||||
// rather than on the surface.
|
||||
export default function Sites() {
|
||||
const { data, error, loading } = usePolled(() => api.sites(), 20000, [])
|
||||
export default function Sites({ user }) {
|
||||
const { data, error, loading, reload } = usePolled(() => api.sites(), 20000, [])
|
||||
// Cameras come from a second call and are joined here rather than server-side:
|
||||
// the picture is decoration on this screen, so it must never be able to make
|
||||
// the health list fail. If this errors the cards simply have no photograph.
|
||||
const { data: cams } = usePolled(() => api.cameras(), 60000, [])
|
||||
const [checking, setChecking] = useState(null)
|
||||
const [opening, setOpening] = useState(false)
|
||||
const sites = data || []
|
||||
// Opening a shop is the owner's: it is a billing and tenancy object, not a
|
||||
// setting. Managers set up the PC and cameras once it exists.
|
||||
const canOpen = user?.role === 'owner'
|
||||
|
||||
if (loading && !data) return <Loading />
|
||||
if (error) return <Problem error={error} />
|
||||
if (!sites.length) return <Empty />
|
||||
if (!sites.length && !opening) return <Empty canOpen={canOpen} onOpen={() => setOpening(true)} />
|
||||
|
||||
// Counted from the same verdicts the cards show. Summarising with a second,
|
||||
// simpler rule up here is how a header ends up reading "all working" over a
|
||||
@@ -51,6 +55,9 @@ export default function Sites() {
|
||||
{fresh > 0 && <> · {fresh} not set up yet</>}
|
||||
{!broken && !watch && !fresh && <> · <b className="ok">all working</b></>}
|
||||
</p>
|
||||
{canOpen && (
|
||||
<button className="primary" onClick={() => setOpening(true)}>Open a new shop</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<div className="grid sites">
|
||||
@@ -64,10 +71,66 @@ export default function Sites() {
|
||||
button on the camera screen that always checked sites[0], so with two
|
||||
shops the second could not be checked at all. */}
|
||||
{checking && <SiteCheck site={checking} onClose={() => setChecking(null)} />}
|
||||
{opening && <NewShop onClose={() => setOpening(false)}
|
||||
onCreated={() => { setOpening(false); reload() }} />}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// Until this existed a shop was a command on the server plus a hand edit of
|
||||
// the broker's password file - every new branch a support ticket, and the one
|
||||
// piece of onboarding that could not be done from the product. The server now
|
||||
// registers the shop's broker login as it creates the row, so the next step
|
||||
// really is just "Set up a shop PC" on the new card.
|
||||
function NewShop({ onClose, onCreated }) {
|
||||
const [form, setForm] = useState({ name: '', slug: '', timezone: 'Asia/Kolkata' })
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
const set = (k) => (e) => setForm({ ...form, [k]: e.target.value })
|
||||
|
||||
const submit = async (e) => {
|
||||
e.preventDefault()
|
||||
setBusy(true); setError('')
|
||||
try {
|
||||
onCreated(await api.createSite({ name: form.name, slug: form.slug || undefined, timezone: form.timezone }))
|
||||
} catch (err) {
|
||||
setError(err.message)
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overlay" onClick={onClose}>
|
||||
<aside className="drawer narrow" onClick={e => e.stopPropagation()}>
|
||||
<header className="drawer-head">
|
||||
<h2>Open a new shop</h2>
|
||||
<button className="ghost" onClick={onClose}>Close</button>
|
||||
</header>
|
||||
<form className="drawer-body" onSubmit={submit}>
|
||||
<label>Shop name
|
||||
<input value={form.name} onChange={set('name')} required autoFocus placeholder="TeNext Bengaluru" />
|
||||
</label>
|
||||
<label>Short name
|
||||
<input value={form.slug} onChange={set('slug')} placeholder="made from the name if left empty" />
|
||||
<span className="hint">
|
||||
Lower-case letters, digits and dashes. It becomes the shop PC’s
|
||||
identity and cannot be changed afterwards.
|
||||
</span>
|
||||
</label>
|
||||
<label>Timezone
|
||||
<input value={form.timezone} onChange={set('timezone')} required />
|
||||
<span className="hint">Footfall is bucketed by the shop’s own clock, e.g. Asia/Kolkata.</span>
|
||||
</label>
|
||||
{error && <p className="error" role="alert">{error}</p>}
|
||||
<button className="primary" disabled={busy || !form.name.trim()}>
|
||||
{busy ? 'Opening…' : 'Open shop'}
|
||||
</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// The one line the card leads with, in severity order. Only the first is shown:
|
||||
// a shop that is offline AND has a bad camera needs its PC turned on first, and
|
||||
// listing both invites someone to start with the wrong one.
|
||||
@@ -272,13 +335,15 @@ export function Problem({ error }) {
|
||||
)
|
||||
}
|
||||
|
||||
function Empty() {
|
||||
function Empty({ canOpen, onOpen }) {
|
||||
return (
|
||||
<div className="state">
|
||||
<h2>No shops yet</h2>
|
||||
<p className="sub">
|
||||
A shop appears here once its PC has been claimed with an enrolment code.
|
||||
{canOpen ? 'Open your first shop, then set up its PC and cameras from its card.'
|
||||
: 'The owner opens shops; they appear here once created.'}
|
||||
</p>
|
||||
{canOpen && <button className="primary" onClick={onOpen}>Open a new shop</button>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user