4 Commits

Author SHA1 Message Date
f88d441bbf A shop can be renamed and, while empty, removed - from head office
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
2026-09-19 16:11:07 +05:30
4ac08e5a85 The tray says why the engine is not running, and setup will not run under a live app
Seen on the demo PC: Start did nothing and Stop stayed grey. The
supervisor's engine had failed because a second engine already held
port 8010, and the tray reported that as nothing at all. The supervisor
now keeps the engine's last lines and turns the known ones into a
sentence - 'port 8010 is already in use - another Behavision or its
engine is still running', 'run behavision-setup again' - which the tray
and the window show. Tray clicks no longer run on the menu loop, so a
stop that waits for the process cannot make the menu look dead.

Two ways that second process came to exist are closed: setup refuses to
run while Behavision.exe or the agent is up, and the app watches
agent.json so a claim made underneath it - which rotates the API token
- is picked up instead of leaving camera sync refused until a restart.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:50:26 +05:30
7c74431fcf Loya says 'sign in again' instead of 'session expired' 2026-09-19 15:29:15 +05:30
01f1c17c7f A claimed PC forgets the old login and the old cameras
Seen on the first claimed demo install: 'session expired' on every
screen, signed in as a user from the previous demo's head office, and
'Watching 3 cameras' for a shop with one - the PC had offered its two
leftover cameras up to head office, without their passwords, so the
same lens was listed twice and one copy could never be pushed anywhere.

Claiming now clears any stored session (a new head office is a new
world), a session whose refresh fails is forgotten on disk as well as
in memory so the app returns to Login by itself, and the demo setup
removes cameras left from an earlier install before it joins the shop,
because head office is the source of truth from then on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
2026-09-19 15:26:23 +05:30
24 changed files with 422 additions and 52 deletions

View File

@@ -92,6 +92,12 @@ func run() error {
}
}
if running := behavisionRunning(); running != "" {
return fmt.Errorf("%s is running. Quit Behavision from the tray icon first, then run setup again.\n\n"+
"Setting up underneath a running copy starts a second engine on the same port and, in a demo,\n"+
"re-claims the shop while the open app still holds the old credentials.", running)
}
py, ver, err := findPython()
if err != nil {
return err
@@ -136,6 +142,16 @@ func run() error {
// Proving it starts is the point. An installer that reports success and
// leaves a shop with an engine that will not run has done worse than
// failing: the failure surfaces later, to someone who did not install it.
// Joining a shop: head office supplies the cameras, so any left on this PC
// from an earlier install go first. Otherwise the reconciler offers them UP
// to head office - without their passwords, which the engine never returns
// - and the shop ends up with the same lens listed twice, one copy of which
// can never be pushed to another PC. Measured on the first claimed demo.
if bundle != nil && bundle.EnrolCode != "" {
if err := os.Remove(paths.CamerasFile()); err == nil {
step("Earlier cameras", "removed - head office supplies them now")
}
}
if err := smokeTest(vpy, demoCams); err != nil {
return fmt.Errorf("the engine installed but would not start: %w", err)
}
@@ -200,6 +216,22 @@ func engineSource() (string, error) {
// `py -3` first on Windows: the launcher is what the official installer puts
// on PATH, and `python` there is often the Microsoft Store stub that prints an
// advert and exits 9009 instead of running anything.
// behavisionRunning names a Behavision process if one is up. Windows only -
// that is the platform setup ships on - and by image name via tasklist, which
// needs no extra privilege.
func behavisionRunning() string {
if runtime.GOOS != "windows" {
return ""
}
for _, name := range []string{"Behavision.exe", "behavision-agent.exe"} {
out, err := exec.Command("tasklist", "/FI", "IMAGENAME eq "+name, "/NH").Output()
if err == nil && strings.Contains(strings.ToLower(string(out)), strings.ToLower(name)) {
return name
}
}
return ""
}
func findPython() (string, string, error) {
type cand struct {
exe string
@@ -523,6 +555,7 @@ func claimShop(code, base string) (string, error) {
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
cfg.Standalone = false
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
if err != nil {
return "", err

View File

@@ -119,6 +119,7 @@ func cmdClaim(args []string) error {
cfg.BrokerPassword = b.MQTTPass
cfg.AgentToken = b.AgentToken
cfg.CloudBase = base
cfg.SessionToken, cfg.SessionRefresh, cfg.SessionEmail = "", "", ""
caPath, err := enrol.SaveCA(b.CACert, paths.BrokerCA())
if err != nil {
return err

View File

@@ -21,6 +21,7 @@ import (
"net/http"
"os"
"os/exec"
"strings"
"sync"
"time"
)
@@ -218,13 +219,26 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
kill = k
defer release()
// The last few lines the engine printed travel with the failure, because
// "engine exited: exit status 1" sends somebody to a log file on a shop
// PC, and the one line that matters - "port 8010 is already in use" - was
// right there.
var tailMu sync.Mutex
var tail []string
pumped := make(chan struct{})
go func() {
defer close(pumped)
sc := bufio.NewScanner(stdout)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
fmt.Fprintln(s.opts.LogWriter, sc.Text())
line := sc.Text()
fmt.Fprintln(s.opts.LogWriter, line)
tailMu.Lock()
tail = append(tail, line)
if len(tail) > 12 {
tail = tail[1:]
}
tailMu.Unlock()
}
}()
@@ -238,6 +252,12 @@ func (s *Supervisor) runOnce(ctx context.Context) error {
return nil
}
if waitErr != nil {
tailMu.Lock()
reason := explain(tail)
tailMu.Unlock()
if reason != "" {
return fmt.Errorf("%s (%v)", reason, waitErr)
}
return fmt.Errorf("engine exited: %w", waitErr)
}
return errors.New("engine exited unexpectedly with status 0")
@@ -373,3 +393,28 @@ func LogFile(path string) (*os.File, error) {
}
return os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600)
}
// explain turns the engine's last output into the sentence the tray shows.
// The cases are the ones seen on real installs; anything else shows the last
// non-empty line verbatim.
func explain(tail []string) string {
last := ""
for _, l := range tail {
low := strings.ToLower(l)
switch {
case strings.Contains(low, "address already in use") || strings.Contains(low, "only one usage of each socket address"):
return "port 8010 is already in use - another Behavision or its engine is still running"
case strings.Contains(low, "no module named behavision"):
return "the engine is not installed in this Python - run behavision-setup again"
case strings.Contains(low, "modulenotfounderror") || strings.Contains(low, "importerror"):
return "the engine is missing a library - run behavision-setup again"
}
if strings.TrimSpace(l) != "" {
last = strings.TrimSpace(l)
}
}
if len(last) > 120 {
last = last[:120] + "…"
}
return last
}

View File

@@ -70,6 +70,12 @@ func APICredentials() string {
return filepath.Join(StateRoot(), "data", "api_credentials.txt")
}
// CamerasFile is the engine's own camera store. The agent never edits it -
// cameras go through the engine's API so passwords are sealed - but setup
// removes it when a PC joins a shop, because head office is the source of
// truth from then on.
func CamerasFile() string { return filepath.Join(StateRoot(), "data", "cameras.json") }
// EnsureState creates the writable tree. Called before anything opens a file
// under it, so a first run on a fresh machine does not fail on a missing dir.
func EnsureState() error {

View File

@@ -125,6 +125,7 @@ func (a *App) startup(ctx context.Context) {
})
a.startPipeline(ctx)
go a.watchConfig(ctx)
// Recognition starts with the app. Until this, the engine only ever
// started when somebody pressed Start - which meant a till that rebooted
@@ -435,6 +436,9 @@ func (a *App) Claim(code string) (SessionInfo, error) {
a.cfg.BrokerPassword = b.MQTTPass
a.cfg.AgentToken = b.AgentToken
a.cfg.CloudBase = a.cloud.Base
// A new head office: whoever was signed in was signed in somewhere else.
a.cfg.SessionToken, a.cfg.SessionRefresh, a.cfg.SessionEmail = "", "", ""
a.cloud.Clear()
caPath, err := enrol.SaveCA(b.CACert, agentpaths.BrokerCA())
if err != nil {
return SessionInfo{}, err
@@ -464,6 +468,62 @@ func (a *App) Claim(code string) (SessionInfo, error) {
// the current config. Only Claim needs it today; it exists as its own method
// because "stop everything that reads the config, then start it" is the part
// that is easy to get half right.
// watchConfig reloads agent.json when something else writes it.
//
// behavision-setup re-run on a PC with the app open re-claims the shop and
// rotates its API token; the running app kept the old one and every camera
// sync was refused from then on - heartbeats still flowed, so head office
// looked fine while the cameras went stale. A claim from `behavision-agent
// claim` does the same. Rather than ask people to restart the app, the app
// watches the file and picks the new credentials up itself.
func (a *App) watchConfig(ctx context.Context) {
path := agentpaths.AgentConfig()
last := mtime(path)
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
now := mtime(path)
if now.IsZero() || now.Equal(last) {
continue
}
last = now
fresh, err := agentcfg.Load(path)
if err != nil {
continue
}
fresh = fresh.WithEngineCredentials(agentpaths.APICredentials())
a.mu.Lock()
changed := fresh.AgentToken != a.cfg.AgentToken || fresh.SiteID != a.cfg.SiteID ||
fresh.BrokerPassword != a.cfg.BrokerPassword || fresh.CloudBase != a.cfg.CloudBase ||
fresh.Standalone != a.cfg.Standalone
if changed {
// Keep this process's live session; a claim clears it in the file
// deliberately, and that is honoured too.
a.cfg = fresh
if fresh.SessionToken == "" {
a.cloud.Clear()
}
}
a.mu.Unlock()
if changed {
a.restartPipeline()
}
}
}
func mtime(path string) time.Time {
st, err := os.Stat(path)
if err != nil {
return time.Time{}
}
return st.ModTime()
}
func (a *App) restartPipeline() {
if a.stopBridge != nil {
a.stopBridge()

File diff suppressed because one or more lines are too long

View File

@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Behavision</title>
<script type="module" crossorigin src="./assets/index-upywadx9.js"></script>
<script type="module" crossorigin src="./assets/index-C-oYbwC6.js"></script>
<link rel="stylesheet" crossorigin href="./assets/index-Z_jL3Bie.css">
</head>
<body>

View File

@@ -40,10 +40,20 @@ export default function App() {
const [helping, setHelping] = useState(false)
useEffect(() => {
(async () => {
try { setSession(await api.session()) } catch { setSession(null) }
setBooting(false)
})()
let alive = true
const load = async () => {
try {
const s = await api.session()
if (alive) setSession(prev => JSON.stringify(prev) === JSON.stringify(s) ? prev : s)
} catch { if (alive) setSession(null) }
if (alive) setBooting(false)
}
load()
// Re-read every few seconds: a session the server has ended - or one
// that never belonged to this head office - must put Login back on
// screen, not leave "session expired" banners on every page.
const id = setInterval(load, 8000)
return () => { alive = false; clearInterval(id) }
}, [])
if (!isDesktop()) {

View File

@@ -36,7 +36,12 @@ export default function Assistant({ session, onClose }) {
const a = await api.ask(history.map(t => ({ role: t.role, text: t.text })))
setTurns([...history, { role: 'assistant', text: a.text, used: a.used ?? [] }])
} catch (e) {
setError(message(e))
const m = message(e)
// Her only failure that is not hers: the login is gone. Say what to do,
// not "session expired" - the shell returns to Login within seconds.
setError(/session expired|unauthori[sz]ed/i.test(m)
? 'You’re signed out of head office, so I can’t look anything up. Sign in again and ask me once more.'
: m)
setTurns(turns) // the question stays in the box, not in the transcript
setDraft(q)
} finally { setBusy(false) }

View File

@@ -108,8 +108,16 @@ func (c *Client) do(ctx context.Context, method, path string, body, out any) err
}
if rerr := c.Refresh(ctx); rerr != nil {
// The refresh token is gone too, so this really is a sign-in, not a
// transient failure. Report it as such so the UI shows the login sheet
// rather than an error dialog.
// transient failure. Forget the session - in memory AND on disk, through
// the same callback that persists rotations - so the app goes back to
// Login instead of showing "session expired" on every screen until
// somebody finds Sign out. Seen on a PC that had been claimed against a
// demo head office and then re-claimed against the real one: the old
// login sat there, dead, for the whole session.
c.Clear()
if c.onRefresh != nil {
c.onRefresh(Session{})
}
return ErrUnauthorized
}
return c.send(ctx, method, path, raw, out)

View File

@@ -101,9 +101,11 @@ func (t *tray) onReady(ctx context.Context) {
case <-t.mOpen.ClickedCh:
runtime.Show(ctx)
case <-t.mStart.ClickedCh:
t.app.StartEngine()
// Never on the menu loop itself: a stop waits for the process to
// exit, and a menu that is deaf for the duration looks broken.
go func() { t.app.StartEngine(); t.refresh() }()
case <-t.mStop.ClickedCh:
t.app.StopEngine()
go func() { t.app.StopEngine(); t.refresh() }()
case <-t.mLogs.ClickedCh:
runtime.BrowserOpenURL(ctx, "file://"+logsDir())
case <-t.mQuit.ClickedCh:
@@ -129,23 +131,29 @@ func (t *tray) poll(ctx context.Context) {
case <-ctx.Done():
return
case <-tick.C:
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
t.refresh()
}
}
}
// refresh redraws the icon and the menu from EngineStatus - the same source
// the window reads, so the two cannot disagree.
func (t *tray) refresh() {
s := t.app.EngineStatus()
state, label := describe(s)
systray.SetIcon(iconFor(state))
systray.SetTooltip("Behavision — " + label)
if t.mStatus != nil {
t.mStatus.SetTitle(label)
}
running := s.State == "running" || s.State == "starting" || s.State == "backoff"
if t.mStart != nil && t.mStop != nil {
if running {
t.mStart.Disable()
t.mStop.Enable()
} else {
t.mStart.Enable()
t.mStop.Disable()
}
}
}
@@ -160,7 +168,9 @@ func describe(s EngineStatus) (state, label string) {
case s.State == "stopped":
return "stopped", "Stopped"
case s.State == "failed":
return "error", "Failed — " + firstLine(s.Error)
// The supervisor's error is already a sentence (port in use, missing
// library); show it whole, because it is the thing to act on.
return "error", "Not running — " + firstLine(s.Error)
case s.State == "backoff":
return "error", fmt.Sprintf("Restarting (%d attempts)", s.Restarts)
case !s.Reachable:

View File

@@ -159,6 +159,9 @@ type Store interface {
// one opened by mistake - and returns its broker username. A shop with
// history is closed, not deleted.
DeleteEmptySite(ctx context.Context, clientID, siteID string) (string, error)
// UpdateSite changes what a person reads - the name, the timezone. Never
// the slug: the shop PC and the broker ACL are keyed on it.
UpdateSite(ctx context.Context, clientID, siteID string, in SiteUpdate) (SiteHealth, error)
// --- enrolment ---
RedeemEnrolment(ctx context.Context, hash []byte) (Enrolment, error)
@@ -301,6 +304,7 @@ func (s *Server) Routes() *http.ServeMux {
mux.HandleFunc("GET /api/sites", s.authed(s.handleSites))
mux.HandleFunc("POST /api/sites", s.authed(s.handleCreateSite))
mux.HandleFunc("DELETE /api/sites/{site}", s.authed(s.handleDeleteSite))
mux.HandleFunc("PATCH /api/sites/{site}", s.authed(s.handleUpdateSite))
// Cameras, onboarded from head office. The shop PC still does the
// connecting - it is the only thing on the camera's network - so these

View File

@@ -554,6 +554,23 @@ func (f *fakeStore) DeleteClient(_ context.Context, clientID string) (ClientRow,
return ClientRow{}, nil, pgx.ErrNoRows
}
func (f *fakeStore) UpdateSite(_ context.Context, _ string, siteID string, in SiteUpdate) (SiteHealth, error) {
f.mu.Lock()
defer f.mu.Unlock()
for i := range f.sites {
if f.sites[i].SiteID == siteID {
if in.Name != nil {
f.sites[i].Name = *in.Name
}
if in.Timezone != nil {
f.sites[i].Timezone = *in.Timezone
}
return f.sites[i], nil
}
}
return SiteHealth{}, pgx.ErrNoRows
}
func (f *fakeStore) DeleteEmptySite(_ context.Context, _ string, siteID string) (string, error) {
f.mu.Lock()
defer f.mu.Unlock()

View File

@@ -4,6 +4,7 @@ import (
"errors"
"net/http"
"strings"
"time"
"github.com/jackc/pgx/v5"
@@ -257,3 +258,57 @@ func (s *Server) handleDeleteSite(w http.ResponseWriter, r *http.Request) {
// ErrSiteInUse is returned by DeleteEmptySite for a shop that has anything
// under it.
var ErrSiteInUse = errors.New("site has cameras or visits")
// PATCH /api/sites/{site} - rename a shop or change its timezone. Owner or
// manager. The slug is not in the body and would be refused by the database
// if it were: it is what the shop PC calls itself and a segment of the broker
// topic, and renaming it would orphan both.
func (s *Server) handleUpdateSite(w http.ResponseWriter, r *http.Request) {
p := PrincipalFrom(r.Context())
if !p.CanManageSites() || p.ClientID == "" {
writeErr(w, http.StatusForbidden, "forbidden", "Only a manager or the owner can change a shop.")
return
}
site, ok := s.resolveSite(w, r, r.PathValue("site"))
if !ok {
return
}
var in SiteUpdate
if err := decode(w, r, &in); err != nil {
badRequest(w, err.Error())
return
}
if in.Name != nil {
n := clip(trim(*in.Name), 120)
if n == "" {
badRequest(w, "The shop needs a name.")
return
}
in.Name = &n
}
if in.Timezone != nil {
if _, err := time.LoadLocation(strings.TrimSpace(*in.Timezone)); err != nil {
badRequest(w, "Unknown timezone. Use an IANA name such as Asia/Kolkata.")
return
}
}
if in.Name == nil && in.Timezone == nil {
badRequest(w, "Nothing to change: give a name or a timezone.")
return
}
out, err := s.Store.UpdateSite(r.Context(), p.ClientID, site, in)
if err != nil {
if errors.Is(err, pgx.ErrNoRows) {
writeErr(w, http.StatusNotFound, "not_found", "No such shop.")
return
}
s.serverError(w, "update site", err)
return
}
s.Store.Audit(r.Context(), AuditEntry{
ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user",
Action: "site.updated", Entity: "site", EntityID: site,
Detail: map[string]any{"name": out.Name, "timezone": out.Timezone},
})
writeJSON(w, http.StatusOK, out)
}

View File

@@ -108,3 +108,22 @@ func TestNoBrokerConfiguredSaysSo(t *testing.T) {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
}
func TestAManagerRenamesAShopButTheSlugStays(t *testing.T) {
s, fs := newServer(t)
seedUser(fs)
seedSite(fs)
sess := login(t, s, "manager@acme.com", "correct horse battery")
rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"name": "TeNext Coimbatore"})
if rec.Code != http.StatusOK {
t.Fatalf("got %d: %s", rec.Code, rec.Body.String())
}
var out SiteHealth
_ = json.Unmarshal(rec.Body.Bytes(), &out)
if out.Name != "TeNext Coimbatore" || out.Slug != "chennai" {
t.Fatalf("renamed wrong: %+v", out)
}
if rec := do(t, s, "PATCH", "/api/sites/chennai", sess.Token, map[string]any{"timezone": "Mars/Olympus"}); rec.Code != http.StatusBadRequest {
t.Fatalf("bad timezone accepted: %d", rec.Code)
}
}

View File

@@ -184,6 +184,13 @@ type NewSite struct {
Password string `json:"-"`
}
// SiteUpdate is the editable part of a shop. Both optional; an absent field
// is left alone.
type SiteUpdate struct {
Name *string `json:"name,omitempty"`
Timezone *string `json:"timezone,omitempty"`
}
type SiteHealth struct {
SiteID string `json:"site_id"`
Slug string `json:"slug"`

View File

@@ -146,3 +146,15 @@ func (s *Store) DeleteEmptySite(ctx context.Context, clientID, siteID string) (s
}
return username, tx.Commit(ctx)
}
func (s *Store) UpdateSite(ctx context.Context, clientID, siteID string, in api.SiteUpdate) (api.SiteHealth, error) {
var out api.SiteHealth
err := s.pool.QueryRow(ctx, `
UPDATE sites
SET name = COALESCE($3, name),
timezone = COALESCE($4, timezone)
WHERE id = $1::uuid AND client_id = $2::uuid
RETURNING id::text, slug, name, timezone`, siteID, clientID, in.Name, in.Timezone).
Scan(&out.SiteID, &out.Slug, &out.Name, &out.Timezone)
return out, err
}

File diff suppressed because one or more lines are too long

View File

@@ -6,8 +6,8 @@
<meta name="color-scheme" content="dark" />
<link rel="icon" type="image/png" href="/favicon.png" />
<title>Behavision</title>
<script type="module" crossorigin src="/assets/index-CAACw-qR.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-KC4SOUb9.css">
<script type="module" crossorigin src="/assets/index-Ckr5hGZd.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-D4KGRSVS.css">
</head>
<body>
<div id="root"></div>

View File

@@ -206,6 +206,8 @@ export const api = {
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)),

View File

@@ -568,3 +568,7 @@ button.ghost.danger:hover { border-color: var(--bad); }
letter-spacing: .04em; text-transform: uppercase; }
.ask-btn .mark { width: 18px; height: 18px; }
.row { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; }
button.ghost.danger { color: var(--bad); border-color: color-mix(in srgb, var(--bad) 40%, transparent); }
input[readonly] { opacity: .6; }

View File

@@ -8,7 +8,7 @@ import { api } from '../api.js'
// recognising almost nobody. Every step names what to do when it fails, and the
// shop is only "working" when all of them pass: a partial pass is not a working
// shop, and calling it one is how that site got signed off.
export default function SiteCheck({ site, onClose }) {
export default function SiteCheck({ site, user, onClose, onChanged }) {
const [result, setResult] = useState(null)
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
@@ -89,6 +89,7 @@ export default function SiteCheck({ site, onClose }) {
)}
<ClaimPC site={site} />
<ShopSettings site={site} user={user} onChanged={onChanged} onClose={onClose} />
</div>
</aside>
</div>
@@ -169,3 +170,74 @@ function expiry(iso) {
if (days <= 0) return 'today'
return days === 1 ? 'tomorrow' : `in ${days} days`
}
// The shop's own details: rename, timezone, and - for a shop opened by mistake
// - removal. The short name is shown but not editable: it is what the shop PC
// calls itself and a segment of the broker topic, so renaming it would orphan
// both. The display name is what people read, and a system that cannot fix a
// typo in a shop's name has confused the two.
function ShopSettings({ site, user, onChanged, onClose }) {
const [name, setName] = useState(site.name)
const [tz, setTz] = useState(site.timezone || 'Asia/Kolkata')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
const [confirming, setConfirming] = useState(false)
const canManage = user?.role === 'owner' || user?.role === 'manager'
const isOwner = user?.role === 'owner'
if (!canManage) return null
const dirty = name.trim() !== site.name || tz.trim() !== (site.timezone || 'Asia/Kolkata')
const save = async (e) => {
e.preventDefault()
setBusy(true); setError('')
try {
await api.updateSite(site.slug || site.site_id, { name: name.trim(), timezone: tz.trim() })
onChanged?.()
} catch (err) { setError(err.message) } finally { setBusy(false) }
}
const remove = async () => {
setBusy(true); setError('')
try {
await api.deleteSite(site.slug || site.site_id)
onChanged?.(); onClose?.()
} catch (err) { setError(err.message); setConfirming(false) } finally { setBusy(false) }
}
return (
<section className="claim">
<h3>Shop details</h3>
<form onSubmit={save}>
<label className="field">
<span>Name</span>
<input value={name} onChange={e => setName(e.target.value)} required />
</label>
<label className="field">
<span>Short name</span>
<input value={site.slug} readOnly />
<span className="hint">Fixed: the shop PC and the broker are keyed on it.</span>
</label>
<label className="field">
<span>Timezone</span>
<input value={tz} onChange={e => setTz(e.target.value)} />
</label>
{error && <p className="error" role="alert">{error}</p>}
<div className="row">
<button className="primary" disabled={busy || !dirty}>{busy ? 'Saving…' : 'Save'}</button>
{isOwner && !confirming && (
<button type="button" className="ghost danger" onClick={() => setConfirming(true)}>Remove this shop…</button>
)}
</div>
</form>
{confirming && (
<div className="banner warn" style={{ marginTop: 12 }}>
<b>Remove {site.name}?</b>
<p className="sub">Only possible while it has no cameras and no visits. A shop with history is kept.</p>
<div className="row">
<button className="primary" disabled={busy} onClick={remove}>{busy ? 'Removing…' : 'Yes, remove it'}</button>
<button className="ghost" onClick={() => setConfirming(false)}>Keep it</button>
</div>
</div>
)}
</section>
)
}

View File

@@ -70,7 +70,7 @@ export default function Sites({ user }) {
{/* Reachable per shop, at last. The smoke test used to hang off a single
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)} />}
{checking && <SiteCheck site={checking} user={user} onClose={() => setChecking(null)} onChanged={reload} />}
{opening && <NewShop onClose={() => setOpening(false)}
onCreated={() => { setOpening(false); reload() }} />}
</>