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:
2026-09-19 11:55:26 +05:30
parent 5f83a1077d
commit 4c750cb2ac
21 changed files with 1310 additions and 54 deletions

View File

@@ -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)),

View File

@@ -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>
)
}