From 3f9fb33b2444203138134582852c90f283140a0b Mon Sep 17 00:00:00 2001 From: Suriyakumarvijayanayagam Date: Sat, 5 Sep 2026 11:45:42 +0530 Subject: [PATCH] Accounts people can create, and photos on a server with no bucket A tenant had exactly the users somebody had created with a command on the server. That is not a missing screen: a shop with an owner and four staff either shared one password or raised a ticket per person, and a phone app for the shop floor could not exist while there was one account to sign in as. Registration is by invitation, never open signup - the same line already drawn around creating a company. The code carries the address and the role and the request carries only a password, so a code that gets forwarded cannot become somebody else's account, and a staff invitation cannot be redeemed as an owner. Single use lives in the UPDATE and the account is created in the same transaction. Deactivating a member revokes their sessions in that transaction too. An access token lives twelve hours, so without it "remove their access" removed it sometime tomorrow. The session list and revoke that go with it are the benefit of opaque tokens the product had been paying for and never collecting: nothing could say what was signed in, let alone stop one. Face images now work on a deployment with no object storage, which was every local install and every self-hosted site - the arrivals feed said "not storing customer photos" for every customer forever, on the screen whose whole job is to show a face. Bounded to one row per visitor, so it grows with the customer base and not with footfall; the bucket stays primary wherever one exists. Image.auth says whether a URL needs the session, because a browser img cannot load one that does, a mobile image view can, and a webview can do neither - the desktop client resolves those to a data URI in Go. Found by running it, not by tests: * UPDATE ... RETURNING gives the value AFTER the update, so the prune read back empty keys, deleted nothing, and the table grew with footfall exactly as if it were not there. The fake agreed with either version; only the live Postgres test caught it. * Trusting only the auth flag broke every shop card, because Sites.jsx rebuilt a partial snapshot object and dropped it. A relative URL is now sufficient on its own. * ago() renders a future time as "just now", so a code valid for a week read "expires just now". Verified live against real Postgres: invite, preview, escalation refused, register into a session, replay 404, staff forbidden, device revoked and 401 at once, last owner refused, and a 92,405-byte camera JPEG stored, served to its owner, 401 with no session, 404 to another tenant, and rendered in a browser. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01HViLj9gYNRtSr7YVZmW5sn --- API.md | 334 +++++++++++++++ CLAUDE.md | 198 +++++++++ agent/pkg/bridge/images.go | 60 +++ agent/pkg/bridge/images_test.go | 78 ++++ desktop/internal/cloud/client.go | 88 ++++ server/internal/api/api.go | 53 +++ server/internal/api/arrivals_test.go | 53 +++ server/internal/api/faces_test.go | 230 +++++++++++ server/internal/api/fake_test.go | 242 ++++++++++- server/internal/api/handlers_arrivals.go | 24 +- server/internal/api/handlers_cameras.go | 5 + server/internal/api/handlers_faces.go | 186 +++++++++ server/internal/api/handlers_images.go | 48 ++- server/internal/api/handlers_sessions.go | 80 ++++ server/internal/api/handlers_team.go | 390 ++++++++++++++++++ server/internal/api/sessions_test.go | 147 +++++++ server/internal/api/team_test.go | 349 ++++++++++++++++ server/internal/api/types.go | 107 +++++ server/internal/store/api_faces.go | 187 +++++++++ server/internal/store/api_faces_live_test.go | 261 ++++++++++++ server/internal/store/api_team.go | 347 ++++++++++++++++ server/internal/store/store.go | 8 + .../web/dist/assets/index-Bgt5SnW3.css | 1 + .../web/dist/assets/index-CtuyPF09.css | 1 - .../web/dist/assets/index-Dv7hKDIX.js | 46 +++ .../web/dist/assets/index-tRretU9M.js | 46 --- server/internal/web/dist/index.html | 4 +- server/migrations/010_invitations.sql | 60 +++ server/migrations/011_visit_faces.sql | 60 +++ web/src/App.jsx | 2 + web/src/api.js | 71 +++- web/src/styles.css | 34 +- web/src/views/Cameras.jsx | 2 +- web/src/views/Live.jsx | 10 +- web/src/views/Login.jsx | 131 +++++- web/src/views/Shot.jsx | 47 ++- web/src/views/Sites.jsx | 27 +- web/src/views/Team.jsx | 214 ++++++++++ 38 files changed, 4125 insertions(+), 106 deletions(-) create mode 100644 API.md create mode 100644 server/internal/api/faces_test.go create mode 100644 server/internal/api/handlers_faces.go create mode 100644 server/internal/api/handlers_sessions.go create mode 100644 server/internal/api/handlers_team.go create mode 100644 server/internal/api/sessions_test.go create mode 100644 server/internal/api/team_test.go create mode 100644 server/internal/store/api_faces.go create mode 100644 server/internal/store/api_faces_live_test.go create mode 100644 server/internal/store/api_team.go create mode 100644 server/internal/web/dist/assets/index-Bgt5SnW3.css delete mode 100644 server/internal/web/dist/assets/index-CtuyPF09.css create mode 100644 server/internal/web/dist/assets/index-Dv7hKDIX.js delete mode 100644 server/internal/web/dist/assets/index-tRretU9M.js create mode 100644 server/migrations/010_invitations.sql create mode 100644 server/migrations/011_visit_faces.sql create mode 100644 web/src/views/Team.jsx diff --git a/API.md b/API.md new file mode 100644 index 0000000..22204c9 --- /dev/null +++ b/API.md @@ -0,0 +1,334 @@ +# Behavision API — for the web console and a mobile app + +Base URL: `https://platform.loyaly.ai` (locally `http://127.0.0.1:8088`). +Everything is JSON unless stated. All times are RFC 3339 UTC unless a field says +otherwise. + +There is **one API**, not a web one and a mobile one. The web console in this +repository uses exactly these calls; anything it can do, an app can do. + +--- + +## 1. Signing in + +### `POST /api/auth/login` + +```json +{ "email": "priya@tenext.in", "password": "…", "device": "Pixel 8" } +``` + +```json +{ + "access_token": "…", + "refresh_token": "…", + "expires_at": "2026-09-05T18:00:00Z", + "user": { "id": "…", "email": "…", "full_name": "Priya R", + "role": "staff", "client_id": "…", "client_name": "TeNext Retail" } +} +``` + +Send `Authorization: Bearer ` on every other call. + +**`device` is worth sending.** It is the only thing that lets somebody look at +their list of signed-in devices and tell which one to sign out. Keep it coarse +and human — `"Pixel 8"`, `"Shop till"` — never a device identifier; a +fingerprint here is a tracking signal nobody asked for. + +Failures: + +| status | `error` | what it means | +|---|---|---| +| 401 | `bad_credentials` | Wrong password **or** no such account. Deliberately the same answer: telling them apart turns this form into a way to find out who works at a customer. Show the server's `message`. | +| 429 | `too_many_attempts` | 10 failures per account / 60 per IP in 15 minutes. Cleared by a success. | + +### `POST /api/auth/refresh` + +```json +{ "refresh_token": "…", "device": "Pixel 8" } +``` + +Returns the same shape. **Both tokens rotate** — the old refresh token stops +working the instant the new one is issued, so a copy taken off a resold device +cannot keep working alongside the real one. + +Three rules a client must follow, and all three have already been the cause of a +bug in this codebase: + +1. **An expired access token returns 401 with `"error": "token_expired"`**, + distinct from a real 401. Refresh once and retry, silently — otherwise staff + are thrown back to a login form twice a day. +2. **Serialise refresh behind one lock.** The refresh token is single use, so + four screens polling at once would each spend it and three would lose, + logging the user out at random. +3. **Persist the rotated tokens before doing anything else.** A client that + refreshes and is then killed comes back holding a token the server has + already invalidated — indistinguishable from a normal expiry, at the worst + possible moment. + +Marshal the request body **before** the first attempt: a retry has to send it +again, and a stream is spent after the first read. + +### `POST /api/auth/logout` · `GET /api/auth/me` + +Logout revokes the calling session. `me` returns the `user` object above. + +--- + +## 2. Joining — how somebody gets an account + +There is **no open registration**, by design. A manager or owner mints a code +and hands it over; the holder chooses their own password. + +### `POST /api/team/invitations` — manager or owner + +```json +{ "email": "arjun@tenext.in", "full_name": "Arjun", "role": "manager", + "expires_in_days": 7 } +``` + +```json +{ "id": "…", "email": "arjun@tenext.in", "role": "manager", + "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", + "expires_at": "…", "created_at": "…" } +``` + +**`code` is returned exactly once and is not recoverable.** Only a hash is +stored. Show it immediately; do not expect to read it back. + +`role` is `staff`, `manager` or `owner`. Only an owner may mint an owner. +`admin` is not accepted at all. + +### `GET /api/auth/invitation?code=…` — **no auth** + +```json +{ "client_name": "TeNext Retail", "email": "arjun@tenext.in", + "full_name": "Arjun", "role": "manager" } +``` + +Call this before asking anyone to choose a password, so the screen can say what +they are joining and a mistyped code is caught early. Unknown, expired, spent +and withdrawn all return **404 `invalid_code`** with one message. + +### `POST /api/auth/register` — **no auth** + +```json +{ "code": "LQOUHR-AYYTPE-7Q756N-PGAAN6", + "full_name": "Arjun", "password": "…", "device": "Pixel 8" } +``` + +Returns **201** and a full session — the same shape as login. Sign the person +straight in; do not send them to a login form. + +**Do not send `email` or `role`.** They come from the invitation, and the +request is rejected outright if it names either. That is what stops a forwarded +code becoming somebody else's account, or a staff invitation being redeemed as +an owner. + +Dashes and case in the code are ignored. A rejected attempt (short password, +wrong code) does **not** spend the invitation. + +| status | `error` | +|---|---| +| 400 | password under 8 characters, or a body naming `email`/`role` | +| 404 | `invalid_code` | +| 409 | `conflict` — that address already has an account; sign in instead | + +### `GET` / `DELETE /api/team/invitations[/{id}]` — manager or owner + +List what is still pending, or withdraw one before it is used. + +--- + +## 3. Devices + +| | | +|---|---| +| `GET /api/auth/sessions` | this account's signed-in devices | +| `DELETE /api/auth/sessions/{id}` | sign one out, immediately | +| `POST /api/auth/sessions/revoke-others` | sign out everywhere else | + +```json +[{ "id": "…", "device": "Pixel 8", "created_at": "…", + "last_used_at": "…", "expires_at": "…", "current": true }] +``` + +`current` marks the session making the request — label it, and warn before +somebody signs out the device in their hand. `revoke-others` deliberately keeps +the caller's own session. + +A person can revoke only their own sessions. To remove a colleague's access, +deactivate them (below); that revokes every session they hold. + +--- + +## 4. The team + +| | | +|---|---| +| `GET /api/team` | everybody in this company | +| `PATCH /api/team/{id}` | `{"role": "manager"}` and/or `{"active": false}` | + +Deactivating signs that person out **immediately** and stops them signing back +in. Reactivating restores the account but not their old sessions. + +409 `last_owner` if the change would leave the company with no active owner. + +--- + +## 5. Who just walked in — the screen a mobile app is for + +### `GET /api/visits` + +`?limit=50&cursor=…&site_id=…` + +```json +{ + "arrivals": [{ + "visit_id": "…", "seq": 412, + "occurred_at": "2026-09-05T06:01:45Z", + "site_id": "…", "site": "TeNext Chennai", "camera_id": "Office1", + "visitor_id": "…", "label": "Priya", + "is_new_visitor": false, "similarity": 0.71, "quality": 0.66, + "attributes": { "gender": "Male", "age": 32, "emotion": "neutral" }, + "image": { "available": true, + "url": "/api/faces/8e7d3d7a-….jpg", "auth": true } + }], + "cursor": "djE6NDEy", + "polled_at": "…" +} +``` + +**Echo `cursor` back on every poll.** It is opaque and it is the only thing that +makes the feed lossless: a burst larger than `limit` leaves rows behind, and +polling by timestamp alone would skip them permanently. Rows are **ascending**, +so the last row's position is your new cursor — which the response already gives +you. A cursor that fails to parse means the format changed; drop it and poll +again without one. + +An empty poll returns your own cursor back, not an empty string. + +### `GET /api/visits/stream` — server-sent events + +The same rows, pushed. Send `Authorization` (so `EventSource` will not do — +read the stream with an HTTP client) and resume with `Last-Event-ID` or +`?cursor=`. Falls back to polling cleanly; the failure mode is latency, never +silence. + +--- + +## 6. Photos + +**A missing photo is data, not an error.** Images are off by default across the +whole product, so on most deployments every arrival legitimately has none. Show +initials or a placeholder — a screen of red for a system working as configured +is a screen whose real errors get ignored. + +```json +"image": { "available": false, + "reason": "This system is not storing customer photos." } +``` + +When a photo **is** available there are two kinds of URL, and the `auth` flag is +how you tell them apart. Do not infer it from the shape of the URL. + +| | `auth` | how to load it | +|---|---|---| +| presigned object-storage link | absent/false | use it directly; it carries its own signature and expires in `expires_in` seconds | +| served by this API | `true` | send `Authorization: Bearer …` | + +- **Mobile**: an image view can attach the header — + `Image source={{ uri, headers: { Authorization: 'Bearer …' } }}`. +- **Web**: an `` cannot. Fetch it and use an object URL + (`URL.createObjectURL`), and **revoke it** on unmount — a screen left open all + afternoon otherwise holds hundreds of copies of the same photograph. + +Prefix a relative URL with the base URL. Treat any relative URL as needing auth +whether or not the flag is set: there is no public one. + +### `GET /api/visitors/{id}/image` + +The same `image` object for one customer's latest photo. 404 `no_image` (nothing +captured) or 404 `images_disabled` (this deployment stores none) — two different +absences, because a shop can act on one and not the other. + +Every hand-out of a photo link is written to the audit log. **Fetch it once per +screen**, not once per component: two components asking for the same face put +two rows in *"who looked at my customers"* for one glance at one person. + +--- + +## 7. Customers + +| | | +|---|---| +| `GET /api/visitors?q=…` | search by name or phone | +| `GET /api/visitors/{id}/history` | their past visits | +| `PUT /api/visitors/{id}/profile` | name, phone, notes — staff and above | +| `DELETE /api/visitors/{id}` | **erasure** — manager and above | +| `POST /api/purchases` | link a sale to a visit | + +Erasure destroys the face template and the photo outright and keeps the visit +rows, unlinked. It is irreversible. If the photo cannot be deleted the whole +request fails with **502** and *nothing* is erased — so an error there means the +data is still there, and must be reported as a failure, never swallowed. + +--- + +## 8. Shops, cameras, reports + +| | | +|---|---| +| `GET /api/sites` | estate health: online, cameras up, `fraction_below_gate` | +| `GET /api/sites/{id}/check` | five-step smoke test for one shop | +| `GET /api/cameras` | cameras and their latest still | +| `GET /api/cameras/{id}/live` | live view relayed from the shop PC (SSE) | +| `GET /api/reports/footfall` | `?from=&to=&site=&tz=&bucket=` | +| `GET /api/reports/conversion` | same parameters; revenue and basket size | + +**The site parameter is spelled differently here.** Reports take `site`; the +arrivals feed takes `site_id`. That is a wart, not a rule — but an unknown query +parameter is silently ignored, so getting it wrong returns the whole estate +rather than an error. + +Dates are `YYYY-MM-DD`. `to` is **inclusive**: "1st to the 7th" includes the +7th. + +Two arithmetic traps the API is explicit about, so a client does not reinvent +them wrongly: + +- **`total` is unique people over the window; the chart does not sum to it.** + Somebody who came Monday and Thursday is one person and two bucket-visitors. + Show the server's `total`, with `visits` underneath. +- **`new + returning` can be less than the total.** A site sending counts + without templates records real footfall by an unidentified person, which + belongs to neither. + +Report buckets are **local wall time with no offset**, labelled by `timezone`. +Do not parse them as a `Date` — the viewer's own zone would shift every label. + +--- + +## 9. Errors + +```json +{ "error": "invalid_code", "message": "That invitation code is not valid. Ask for a new one." } +``` + +`message` is written to be shown to a person; prefer it over inventing your own. +`error` is the stable code to branch on — **never match on the prose**, which is +rewritten freely. + +| status | meaning | +|---|---| +| 400 | the request was wrong; `message` says how | +| 401 | not signed in, or `token_expired` → refresh once and retry | +| 403 | signed in, but this role may not | +| 404 | not found — **also** what another tenant's data returns, always | +| 409 | a conflict `message` explains (`last_owner`, duplicate address) | +| 429 | throttled | +| 501 | the feature is off for this deployment, not an error | +| 502 | a downstream failure; for erasure it means **nothing was deleted** | + +Roles, in increasing order: `staff` → `manager` → `owner`. A platform admin has +`role: "admin"` **and an empty `client_id`** — the two together, never the role +alone. diff --git a/CLAUDE.md b/CLAUDE.md index 2b91442..f8cba9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2408,3 +2408,201 @@ not in normal running — but the margin is what makes `/api/health` reporting already holds **17 embeddings tagged `w600k_mbf` and 19 tagged `w600k_r50`**: proof that the fallback has silently fired before, and that model-tagging is what stopped it corrupting anything. + +## Accounts: how a second person gets one (`invitations`, migration 010) + +A tenant had exactly the users `provision user` had created on the server's +command line. That is not a missing screen, it is a missing product: a shop with +an owner and four staff either shared one password between five people or raised +a support ticket per person, and **a phone app for shop-floor staff could not +exist at all** while there was only ever one account to sign in as. + +Registration is by **invitation**, never open signup — the same line +`handlers_admin.go` already draws around creating a company. An endpoint a +stranger can call to create an account is a far larger thing to secure than one +reachable only through somebody who already has one. + +``` +POST /api/team/invitations manager+ -> the code, ONCE +GET /api/auth/invitation?code=… unauthenticated preview +POST /api/auth/register unauthenticated -> a SESSION +``` + +- **The code decides the address and the role; the request decides only the + password and a display name.** A code gets forwarded, screenshotted and + pasted into chat, so if the body could name either, one staff invitation would + be an owner account for anybody who saw it. `decode` rejects unknown fields, + so a client cannot even ask — verified live: `unknown field "role"` → 400. +- **`register` returns a session, not a 201.** Sending somebody who chose a + password four seconds ago to a sign-in form to type it again is the sort of + thing that gets blamed on the password. +- **Single use is enforced by the UPDATE** (`used_at IS NULL` and the write are + one statement) and the account is created **in the same transaction**. A spent + invitation with no user is unusable and invisible; a user with the invitation + still open is a second account waiting for whoever else has the code. Same + rule, same reason, as agent enrolment. +- **Unknown, expired, spent and revoked read identically.** The difference only + helps somebody guessing, and the holder's next step is the same in all four. +- **`admin` is not an invitable role.** A platform administrator is defined by + having *no* client, so an invitation — which always carries one — could never + mint a real one. What it *could* do is create the tenant-scoped `role='admin'` + row that `adminOnly` exists to reject, so it is refused at the constraint. +- **A manager cannot mint an owner.** Promoting somebody past yourself is an + escalation, and it is the shape of this endpoint that matters if a manager + account is ever taken over. +- A failed attempt (short password, mistyped code) does **not** spend the + invitation. One typo must not cost somebody their invitation. + +### Removing access has to mean now + +`PATCH /api/team/{id}` with `{"active": false}` revokes every session that user +holds **in the same transaction**. An access token lives twelve hours, so +without that, "remove their access" removes it sometime tomorrow — which is not +what anybody pressing that button believes they have just done. + +`OwnerCount` refuses the change that locks a company out of itself: the last +active owner may not demote or deactivate themselves. There is no way back from +that except a shell on the server, which is precisely what this surface exists +to stop needing. + +### Devices: the benefit of opaque tokens, finally collected + +`GET /api/auth/sessions`, `DELETE /api/auth/sessions/{id}`, +`POST /api/auth/sessions/revoke-others`. + +The argument for a session table over JWTs was always that this system puts +customer data on shop-floor PCs and staff phones that get lost, resold and +shared — so *"log that device out, now"* has to actually work. **Nothing could +list what was signed in, let alone stop one.** The cost was being paid and the +benefit was not being collected. + +- A person may revoke only their **own** sessions; the store scopes the update + by `user_id`, because a session id travels in that list and is not a secret. + Removing a colleague's access is a different question with a different answer + (deactivate them). +- **"Sign out everywhere else" keeps the caller's own session.** Somebody who + has just lost a phone must not also be signed out of the device they are + holding while they deal with it. +- `device` is a coarse label (`"Chrome on Mac"`), never a fingerprint. The + question it answers is only *"which of these is the one in my hand"*. + +## Face images without an object-storage bucket (`visit_faces`, migration 011) + +009 did this for camera snapshots and its own comment says why face images are +different: *"Face images grow with every visitor who ever walks in, which is why +they stay in a bucket."* That is true of images kept **per visit**, and it is +exactly why this table is bounded to **one row per visitor** instead. + +The gap it closes is the one 009 closed a level up. With no bucket the API +answered *"This system is not storing customer photos"* for every arrival, +forever — including on the mobile feed, whose entire purpose is to put a face in +front of somebody so they can recognise the customer walking towards them. Every +local install and every self-hosted customer who does not want an S3 account got +nothing. + +``` +engine data/outbox/.jpg (only when app.store_faces is on) +agent POST /api/agent/upload-url -> 501 images_disabled + POST /api/agent/faces -> {"key": "db:"} +server visits.image_key = 'db:…' +staff GET /api/visits -> {"image":{"available":true, + "url":"/api/faces/.jpg", + "auth":true}} +``` + +What makes this acceptable in Postgres when per-visit images are not: + +- **The engine still gates capture.** `app.store_faces` is false by default and + no crop is written without it. This changes what happens to an image that + already exists; it does not change whether one is taken. +- **One row survives per visitor.** `RecordVisit` prunes the previous row as it + links a newer one, so storage is (customers × ~20 KB) — it grows with the + customer base, not with footfall. A shop seen by 5,000 people holds ~100 MB + whether they visit once or a thousand times. +- **Nothing reads a superseded face anyway.** Every surface shows the customer's + latest view, which is what `VisitorImageKey` has always returned. +- **Orphans are swept.** An agent uploads before the server has decided who the + person is, so a row is briefly unreferenced by design — and permanently so if + the visit that would have claimed it never arrives. That is a stored + photograph of a real person that erasure could never reach, because erasure + finds images through the visitor and this row has none. + +The bucket stays primary wherever one exists: a presigned PUT never passes the +bytes through the API at all, which is what makes it the right route at estate +scale. The fallback is chosen by the **sentinel** `bridge.ErrImagesOff`, never +by matching a message — getting that wrong from prose somebody later rewords +would silently stop every customer photo in the estate. Same rule the camera +snapshot fallback already follows. + +### `UPDATE … RETURNING` returns the value AFTER the update + +The prune's first version read the superseded keys with +`UPDATE visits SET image_key = '' … RETURNING image_key`. Postgres returns the +**new** row, so every key came back as the empty string it had just been set to, +the delete list was always empty, and `visit_faces` grew with footfall exactly +as if the prune did not exist. The visit rows looked perfectly correct; only the +row count gave it away. + +It is one CTE now — `doomed` reads the pre-image and drives both the update and +the delete — which cannot have that bug. **The in-memory fake would have agreed +with either version**; only `TestLiveOnlyOneFaceSurvivesPerVisitor` against a +real Postgres caught it, which is the whole reason the live store tests exist. + +### `Image.auth`, and one function that decides where a photo is + +`s.imageFor(key)` is the single place that turns a stored key into the `Image` a +client receives — the arrivals feed, the live stream and the customer record all +go through it. There are now two places an image can live and four distinct +reasons there may not be one, and computing that twice is how the shops screen +once ended up labelled **Working** in green directly above *"2 of 3 cameras not +connecting"*. + +`auth: true` says the URL is one of ours and needs the session's bearer, rather +than a presigned link carrying its own signature. It exists because the two are +genuinely different to fetch and **a client cannot tell them apart by looking**: + +- A browser `` **cannot** load the authenticated one — no header — so the + web app fetches it and hands over an object URL (`Shot.jsx`). +- A **mobile** image view *can* attach the header and load it directly. +- The **desktop** webview can do neither: a relative src resolves against + `wails://`, not the cloud. `cloud.VisitorImage` therefore fetches the bytes in + Go, where the session already lives, and returns a `data:` URI. The + alternative — a local proxy inside the app holding the session — is a second + authenticated surface on a shop PC to get wrong. + +**Both signals are accepted, and that is not belt-and-braces.** A relative URL +always needs the session; there is no public one. Trusting only the flag broke +every shop card the moment `Sites.jsx`'s `bestView()` rebuilt a partial +`{url, at}` copy and dropped it — found by opening the page, not by a test. The +flag adds only the case a URL cannot express: an absolute link that still needs +a bearer, which arrives the first time object storage is served from this host. + +The bytes endpoint writes **no audit row**. Every read of a face is recorded +where the *link* is handed out — one row per arrivals page, one per customer +record — and the bucket route's bytes never touch this server, so counting the +fetch as well would count one deployment twice and the other once. + +`ago()` clamps at zero and renders a future timestamp as *"just now"*. That is +right for a heartbeat whose clock runs slightly ahead and completely wrong for +an expiry: a code valid for a week read *"expires just now"*, which tells the +operator not to bother handing it over. `until()` is its opposite number. + +### Verified live, 5 September 2026 + +Against real Postgres, on the demo tenant: + +- Owner invites a staff member → code minted once → unauthenticated preview + names the company, address and role → a body naming `role` or `email` is + refused → proper redemption returns a **signed-in session** → replay 404s. +- Staff can read arrivals, shops and the team; **cannot** invite (403). +- Two devices listed, the calling one marked `current`; revoking the phone 401s + its token immediately while the till keeps working. +- Deactivating a member 401s their live session **at once**, and they cannot + sign back in. The only owner cannot demote themselves (409 `last_owner`). +- Agent enrols → `upload-url` answers **501 images_disabled** → falls back to + `POST /api/agent/faces` → a 92,405-byte office-camera JPEG stored in Postgres, + served as `image/jpeg` to the owner, **401 with no session**, **404 to another + tenant**, and rendered in the arrivals feed avatar in a real browser. +- HTML, PDF, GIF and empty bodies are all refused as face images: the check is + on the magic bytes, never the `Content-Type` header, because this endpoint + stores what it is handed and serves it back to a browser. diff --git a/agent/pkg/bridge/images.go b/agent/pkg/bridge/images.go index 90d0316..efee0be 100644 --- a/agent/pkg/bridge/images.go +++ b/agent/pkg/bridge/images.go @@ -106,6 +106,18 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string, } target, err := u.target(ctx) + if errors.Is(err, ErrImagesOff) { + // No object storage on this server. Send the bytes to the API itself, + // which holds them for a deployment that has no bucket - the same + // fallback camera snapshots already take, and chosen by the SENTINEL + // rather than by matching the message, because a prose change would + // otherwise silently stop every photo in the estate. + // + // Only after target() has spoken. The unclaimed case returns the same + // sentinel from the guard at the top of this function, and a PC with no + // credentials has no server to PUT to either. + return u.uploadDirect(ctx, body) + } if err != nil { return "", err } @@ -135,6 +147,54 @@ func (u *SpacesUploader) UploadBytes(ctx context.Context, body []byte) (string, return target.Key, nil } +// uploadDirect posts the image to our own API, for a deployment with no bucket. +// +// Deliberately the second choice. A presigned PUT never passes a photograph +// through the server at all, which is what makes it the right route wherever +// object storage exists; this one is what stops "no S3 account" from meaning +// "no customer photo, ever" on every local install and every self-hosted site. +// +// The server decides where it lands and returns the key, exactly as the +// presigned route does. That symmetry is the point: the caller cannot tell +// which route ran, so the queued visit, the read path and erasure all stay +// single implementations. +func (u *SpacesUploader) uploadDirect(ctx context.Context, body []byte) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, + strings.TrimRight(u.BaseURL, "/")+"/api/agent/faces", bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Authorization", "Bearer "+u.Token) + req.Header.Set("Content-Type", "image/jpeg") + req.ContentLength = int64(len(body)) + + resp, err := u.httpClient().Do(req) + if err != nil { + return "", fmt.Errorf("upload face: %w", err) + } + defer resp.Body.Close() + if resp.StatusCode == http.StatusNotImplemented { + // This server stores no images at all. Stop trying rather than retry + // every visitor forever. + return "", ErrImagesOff + } + if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated { + msg, _ := io.ReadAll(io.LimitReader(resp.Body, 4<<10)) + return "", fmt.Errorf("upload face returned %s: %s", + resp.Status, strings.TrimSpace(string(msg))) + } + var out struct { + Key string `json:"key"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, 8<<10)).Decode(&out); err != nil { + return "", err + } + if out.Key == "" { + return "", errors.New("server stored the face but named no key for it") + } + return out.Key, nil +} + func (u *SpacesUploader) target(ctx context.Context) (uploadTarget, error) { var out uploadTarget req, err := http.NewRequestWithContext(ctx, http.MethodPost, diff --git a/agent/pkg/bridge/images_test.go b/agent/pkg/bridge/images_test.go index 03d2153..3c56f9b 100644 --- a/agent/pkg/bridge/images_test.go +++ b/agent/pkg/bridge/images_test.go @@ -1,6 +1,7 @@ package bridge import ( + "bytes" "context" "encoding/json" "errors" @@ -200,3 +201,80 @@ func TestNoUploaderMeansNoImageAndNoLeftovers(t *testing.T) { t.Fatal("the local image was left on disk") } } + +// A deployment with no object storage must still get a photo onto the customer +// record. Until the fallback existed, `images_disabled` meant every local +// install and every self-hosted site showed no face for anybody, forever. +func TestNoBucketFallsBackToTheServer(t *testing.T) { + var askedURL, postedFace bool + var gotBody []byte + var gotAuth, gotType string + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/agent/upload-url": + askedURL = true + // What a server with no bucket answers. + w.WriteHeader(http.StatusNotImplemented) + _, _ = w.Write([]byte(`{"error":"images_disabled"}`)) + case "/api/agent/faces": + postedFace = true + gotAuth = r.Header.Get("Authorization") + gotType = r.Header.Get("Content-Type") + gotBody, _ = io.ReadAll(r.Body) + w.WriteHeader(http.StatusCreated) + _, _ = w.Write([]byte(`{"key":"db:11111111-1111-4111-8111-111111111111"}`)) + default: + t.Errorf("unexpected request to %s", r.URL.Path) + w.WriteHeader(http.StatusNotFound) + } + })) + defer srv.Close() + + u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()} + img := []byte{0xFF, 0xD8, 0xFF, 0xE0, 'x', 'y', 'z'} + key, err := u.UploadBytes(context.Background(), img) + if err != nil { + t.Fatalf("upload: %v", err) + } + if !askedURL { + t.Error("the presigned route must be tried first - it is the right one where a bucket exists") + } + if !postedFace { + t.Fatal("no fallback upload was made") + } + if !strings.HasPrefix(key, "db:") { + t.Errorf("want the server's own key, got %q", key) + } + if !bytes.Equal(gotBody, img) { + t.Error("the bytes sent are not the bytes given") + } + if gotAuth != "Bearer agent-token" || gotType != "image/jpeg" { + t.Errorf("auth %q type %q", gotAuth, gotType) + } +} + +// A server that stores no images AT ALL must stop the agent trying, rather than +// have it retry every visitor forever. Distinct from a failure, which is why +// it is a sentinel and not a message. +func TestAServerThatStoresNothingSaysSoOnce(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) + })) + defer srv.Close() + + u := &SpacesUploader{BaseURL: srv.URL, Token: "agent-token", Client: srv.Client()} + _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8, 0xFF, 0xE0}) + if !errors.Is(err, ErrImagesOff) { + t.Fatalf("want ErrImagesOff so the caller stops trying, got %v", err) + } +} + +// An unclaimed PC has no server to send anything to. The fallback must not fire +// there - it would be a request to nowhere on every single visit. +func TestAnUnclaimedAgentDoesNotTryToUpload(t *testing.T) { + u := &SpacesUploader{} // no BaseURL, no token + if _, err := u.UploadBytes(context.Background(), []byte{0xFF, 0xD8}); !errors.Is(err, ErrImagesOff) { + t.Fatalf("want ErrImagesOff, got %v", err) + } +} diff --git a/desktop/internal/cloud/client.go b/desktop/internal/cloud/client.go index c410740..dbb7caf 100644 --- a/desktop/internal/cloud/client.go +++ b/desktop/internal/cloud/client.go @@ -9,6 +9,7 @@ package cloud import ( "bytes" "context" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -393,6 +394,11 @@ type Photo struct { ExpiresIn int `json:"expires_in"` Available bool `json:"available"` Reason string `json:"reason"` + // Auth is set by the server when the URL is one of its own endpoints and + // needs this session's bearer, rather than a presigned object-store link + // that carries its own signature. It never reaches the front end - see + // VisitorImage, which resolves it here. + Auth bool `json:"auth"` } // VisitorImage fetches a short-lived signed link to this customer's photo. @@ -413,9 +419,91 @@ func (c *Client) VisitorImage(ctx context.Context, id string) (Photo, error) { return Photo{}, err } out.Available = out.URL != "" + + // A deployment with no object storage serves the photo from the API itself, + // which means a RELATIVE url that needs this session's bearer. Neither + // works in the window: a webview resolves a relative src against + // wails://, not against the cloud, and it cannot send an Authorization + // header at all - so handing it straight through renders a broken picture + // on exactly the deployments that have just started storing photos. + // + // Fetched here and passed as a data: URI. The alternative is a local proxy + // inside this process holding the session, which is a second authenticated + // surface on the shop PC to get wrong. One photo per sheet, ~90 KB, and the + // server already records the read where the link was handed out. + if out.Available && out.Auth { + data, err := c.fetchImage(ctx, out.URL) + if err != nil { + // The record itself is worth far more than the picture, so this is + // an absence with a reason rather than a failure that blanks the + // customer - the same rule the whole image path follows. + return Photo{Reason: "That photo could not be loaded."}, nil + } + out.URL = data + out.Auth = false + } return out, nil } +// fetchImage reads an image this server holds itself and returns a data: URI. +// +// Deliberately not routed through send(): that decodes JSON into `out`, and +// these are bytes. It shares the token and the expiry retry, because a sheet +// opened twelve hours after the last one must not show a broken photo. +func (c *Client) fetchImage(ctx context.Context, path string) (string, error) { + body, err := c.imageBytes(ctx, path) + if errors.Is(err, errTokenExpired) { + if rerr := c.Refresh(ctx); rerr != nil { + return "", rerr + } + body, err = c.imageBytes(ctx, path) + } + if err != nil { + return "", err + } + return "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(body), nil +} + +// maxPhotoBytes bounds what will be pulled into memory and then base64'd into +// the window. Face crops are ~20 KB and a camera still ~100 KB; anything near +// this is a different file or a fault, and a shop PC should not spend its +// memory finding that out. +const maxPhotoBytes = 4 << 20 + +func (c *Client) imageBytes(ctx context.Context, path string) ([]byte, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.Base+path, nil) + if err != nil { + return nil, err + } + c.mu.RLock() + tok := c.token + c.mu.RUnlock() + if tok != "" { + req.Header.Set("Authorization", "Bearer "+tok) + } + resp, err := c.http.Do(req) + if err != nil { + return nil, fmt.Errorf("cannot reach %s: %w", c.Base, err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusUnauthorized { + var e struct { + Error string `json:"error"` + } + body, _ := io.ReadAll(io.LimitReader(resp.Body, 8192)) + _ = json.Unmarshal(body, &e) + if e.Error == "token_expired" { + return nil, errTokenExpired + } + return nil, ErrUnauthorized + } + if resp.StatusCode >= 400 { + return nil, fmt.Errorf("photo: %s", resp.Status) + } + return io.ReadAll(io.LimitReader(resp.Body, maxPhotoBytes)) +} + // ForgetVisitor erases a customer: face template, photo and profile. // // Irreversible by design — a soft-deleted face template is a retained diff --git a/server/internal/api/api.go b/server/internal/api/api.go index b369da9..45552d0 100644 --- a/server/internal/api/api.go +++ b/server/internal/api/api.go @@ -42,6 +42,27 @@ type Store interface { SessionByRefresh(ctx context.Context, hash []byte) (auth.Principal, time.Time, error) RotateSession(ctx context.Context, sessionID string, s NewSession) error RevokeSession(ctx context.Context, sessionID string) error + // Which devices are signed in, and signing one of them out. This is what + // an opaque-token session table buys over a JWT, and until these existed + // the product paid the cost of that choice without the benefit. + UserSessions(ctx context.Context, userID string) ([]DeviceSession, error) + RevokeUserSession(ctx context.Context, userID, sessionID string) error + RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) + + // --- team and invitations --- + // Registration is by invitation: the code carries the address and the role + // so neither can be chosen by whoever redeems it. + CreateInvitation(ctx context.Context, in NewInvitation) (Invitation, error) + PendingInvitations(ctx context.Context, clientID string) ([]Invitation, error) + RevokeInvitation(ctx context.Context, clientID, id string) error + InvitationByCode(ctx context.Context, hash []byte) (InvitationPreview, error) + // RedeemInvitation spends the code and creates the account in ONE + // transaction: a spent invitation with no user behind it is unusable, and a + // user with the invitation still open is a second account waiting for + // whoever else was forwarded the code. + RedeemInvitation(ctx context.Context, hash []byte, fullName, passwordHash string) (UserRecord, error) + Team(ctx context.Context, clientID string) ([]TeamMember, error) + UpdateTeamMember(ctx context.Context, clientID, userID string, up TeamUpdate) (TeamMember, error) // --- reports --- Footfall(ctx context.Context, q ReportQuery) ([]FootfallPoint, Totals, error) @@ -98,6 +119,11 @@ type Store interface { AgentByToken(ctx context.Context, hash []byte) (AgentPrincipal, error) // --- images --- + // Face images held by this server, for a deployment with no object + // storage. Where a bucket is configured none of these three is called. + PutVisitFace(ctx context.Context, clientID, siteID string, jpeg []byte) (string, error) + VisitFace(ctx context.Context, clientID, key string) ([]byte, error) + DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error VisitorImageKey(ctx context.Context, clientID, visitorID string) (string, error) VisitorImageKeys(ctx context.Context, clientID, visitorID string) ([]string, error) ForgetVisitor(ctx context.Context, clientID, visitorID string) error @@ -196,6 +222,26 @@ func (s *Server) Routes() *http.ServeMux { mux.HandleFunc("POST /api/auth/refresh", s.handleRefresh) mux.HandleFunc("POST /api/auth/logout", s.authed(s.handleLogout)) mux.HandleFunc("GET /api/auth/me", s.authed(s.handleMe)) + // Registration. Unauthenticated for the same reason agent enrolment is: + // whoever is doing this has no account yet, and requiring one first would + // mean shipping a password to everybody who needs one. + mux.HandleFunc("GET /api/auth/invitation", s.handleInvitationPreview) + mux.HandleFunc("POST /api/auth/register", s.handleRegister) + // Devices. A person may list and revoke their own sessions; removing a + // colleague's access is a different question, answered by deactivating them + // on the team endpoint below. + mux.HandleFunc("GET /api/auth/sessions", s.authed(s.handleSessions)) + mux.HandleFunc("DELETE /api/auth/sessions/{id}", s.authed(s.handleRevokeSession)) + mux.HandleFunc("POST /api/auth/sessions/revoke-others", + s.authed(s.handleRevokeOtherSessions)) + + // --- the people who work here --- + mux.HandleFunc("GET /api/team", s.authed(s.handleTeam)) + mux.HandleFunc("PATCH /api/team/{id}", s.authed(s.handleUpdateTeamMember)) + mux.HandleFunc("GET /api/team/invitations", s.authed(s.handleInvitations)) + mux.HandleFunc("POST /api/team/invitations", s.authed(s.handleInvite)) + mux.HandleFunc("DELETE /api/team/invitations/{id}", + s.authed(s.handleRevokeInvitation)) mux.HandleFunc("GET /api/reports/footfall", s.authed(s.handleFootfall)) mux.HandleFunc("GET /api/reports/conversion", s.authed(s.handleConversion)) @@ -250,6 +296,8 @@ func (s *Server) Routes() *http.ServeMux { mux.HandleFunc("POST /api/agent/enrol", s.handleEnrol) // Authenticated by the agent's own API token, not a user session. mux.HandleFunc("POST /api/agent/upload-url", s.agentAuthed(s.handleUploadURL)) + // The fallback the agent takes when upload-url answers images_disabled. + mux.HandleFunc("POST /api/agent/faces", s.agentAuthed(s.handlePutFace)) // What this shop PC should be running, and what it reports back. mux.HandleFunc("GET /api/agent/cameras", s.agentAuthed(s.handleAgentCameras)) mux.HandleFunc("POST /api/agent/cameras", s.agentAuthed(s.handleAgentCameraReport)) @@ -262,6 +310,11 @@ func (s *Server) Routes() *http.ServeMux { mux.HandleFunc("POST /api/agent/checks", s.agentAuthed(s.handleAgentCheckResult)) mux.HandleFunc("GET /api/visitors/{id}/image", s.authed(s.handleVisitorImage)) + // The bytes of a face this server holds itself. Session-authenticated + // rather than a signed link: there is no third party to delegate to, and an + // unauthenticated URL would be a way to reach a customer's photograph with + // no session at all. + mux.HandleFunc("GET /api/faces/{id}", s.authed(s.handleGetFace)) // The erasure path. Destroys the template and the photo; keeps the // anonymous visit counts, which are legitimate aggregate data. mux.HandleFunc("DELETE /api/visitors/{id}", s.authed(s.handleForgetVisitor)) diff --git a/server/internal/api/arrivals_test.go b/server/internal/api/arrivals_test.go index c52c2e0..af49ff3 100644 --- a/server/internal/api/arrivals_test.go +++ b/server/internal/api/arrivals_test.go @@ -200,6 +200,11 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) { s.Blob = nil seedUser(fs) seedArrivals(fs, 1) + // No key, because that is what this deployment actually produces: the + // engine's `app.store_faces` is off, so no crop is ever captured and no + // key is ever written. A bucket key on a server with no bucket is a + // different state entirely and gets its own sentence below. + fs.arrivals[0].ImageKey = "" sess := login(t, s, "manager@acme.com", "correct horse battery") page := getPage(t, s, "/api/visits", sess.Token) @@ -212,6 +217,54 @@ func TestNoPhotoIsDataNotAnError(t *testing.T) { } }) + // Three absences now, not two: face images may live in a bucket OR in this + // database, so "there is no bucket" stopped being a synonym for "there are + // no photos" the moment the fallback existed. + t.Run("a bucket key on a server that has lost its bucket", func(t *testing.T) { + s, fs := newServer(t) + s.Blob = nil + seedUser(fs) + seedArrivals(fs, 1) // seeded with an object-store key + sess := login(t, s, "manager@acme.com", "correct horse battery") + + page := getPage(t, s, "/api/visits", sess.Token) + got := page.Arrivals[0].Image + if got.Available { + t.Fatalf("nothing can be served without the bucket, got %+v", got) + } + // Deliberately NOT "we store no photos". The photo exists and this + // server can no longer reach it, which is a configuration fault + // somebody can fix - and reporting it as an ordinary empty record is + // how it would go unnoticed for a year. + if !strings.Contains(got.Reason, "no longer reach") { + t.Errorf("want a configuration reason, got %q", got.Reason) + } + }) + + t.Run("a face this server holds itself", func(t *testing.T) { + s, fs := newServer(t) + s.Blob = nil // no object storage anywhere + seedUser(fs) + seedArrivals(fs, 1) + fs.arrivals[0].ImageKey = "db:00000000-0000-4000-b000-000000000001" + sess := login(t, s, "manager@acme.com", "correct horse battery") + + page := getPage(t, s, "/api/visits", sess.Token) + got := page.Arrivals[0].Image + if !got.Available { + t.Fatalf("a stored face should be offered, got %+v", got) + } + // Auth is what tells a client this URL needs the session bearer. A + // browser cannot load it and a mobile image view can, and there + // is nothing in the URL itself that says so. + if !got.Auth { + t.Error("a face held by this server must be marked as needing auth") + } + if strings.Contains(got.URL, "db:") { + t.Errorf("the storage key leaked into the URL: %q", got.URL) + } + }) + t.Run("this visit simply had none", func(t *testing.T) { s, fs := newServer(t) s.Blob = &fakeBlob{} diff --git a/server/internal/api/faces_test.go b/server/internal/api/faces_test.go new file mode 100644 index 0000000..5ca4e91 --- /dev/null +++ b/server/internal/api/faces_test.go @@ -0,0 +1,230 @@ +package api + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" +) + +// Face images held by this server, for a deployment with no object storage. +// +// The property under test throughout is that the two storage routes differ in +// exactly one hop: the key is minted differently and everything downstream - +// ingest, the feed, the customer record, erasure - is one implementation. + +func putFace(t *testing.T, srv *Server, token string, body []byte) *httptest.ResponseRecorder { + t.Helper() + rr := httptest.NewRecorder() + req := httptest.NewRequest(http.MethodPost, "/api/agent/faces", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer "+token) + srv.Routes().ServeHTTP(rr, req) + return rr +} + +func TestAnAgentStoresAFaceAndAPersonReadsItBack(t *testing.T) { + srv, fs := newServer(t) + srv.Blob = nil // no object storage anywhere: the case this exists for + fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) + seedUser(fs) + + img := jpegBytes(512) + rr := putFace(t, srv, "agent-token", img) + if rr.Code != http.StatusCreated { + t.Fatalf("upload: %d %s", rr.Code, rr.Body) + } + var out struct { + Key string `json:"key"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + // A prefixed key, so `visits.image_key` can name an object in either store + // and the read path can tell which without a second lookup. + if !strings.HasPrefix(out.Key, "db:") { + t.Fatalf("want a db: key, got %q", out.Key) + } + // The tenant and the site come from the AGENT's credential, never the + // request, so a shop PC cannot file an image under another company. + if fs.lastFaceClient != "client-acme" || fs.lastFaceSite != "site-1" { + t.Fatalf("stored against %s/%s", fs.lastFaceClient, fs.lastFaceSite) + } + + sess := login(t, srv, "manager@acme.com", "correct horse battery") + rec := do(t, srv, "GET", faceURL(out.Key), sess.Token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("read back: %d %s", rec.Code, rec.Body.String()) + } + if got := rec.Header().Get("Content-Type"); got != "image/jpeg" { + t.Errorf("content type %q", got) + } + if !bytes.Equal(rec.Body.Bytes(), img) { + t.Error("the bytes that came back are not the ones that went in") + } +} + +// This endpoint stores what it is handed and serves it back to a browser, so +// the one thing it must not become is a way to park arbitrary content under a +// URL this server will serve. Checked against the bytes, never the header. +func TestOnlyAJPEGIsStoredAsAFace(t *testing.T) { + srv, fs := newServer(t) + fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) + + for _, body := range []string{ + "", + "GIF89a", + "%PDF-1.4", + "", + } { + rr := putFace(t, srv, "agent-token", []byte(body)) + if rr.Code == http.StatusCreated { + t.Errorf("accepted %q as a face image", body) + } + } +} + +func TestAFaceIsNotReadableWithoutASession(t *testing.T) { + srv, fs := newServer(t) + fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) + rr := putFace(t, srv, "agent-token", jpegBytes(64)) + var out struct { + Key string `json:"key"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &out) + + // The reason it is session-authenticated rather than a signed link: there + // is no third party to delegate to, and an unauthenticated URL would be a + // way to reach a customer's photograph with no session at all. + if rec := do(t, srv, "GET", faceURL(out.Key), "", nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("a face was served with no session, got %d", rec.Code) + } +} + +func TestAnotherTenantCannotReadYourStoredFace(t *testing.T) { + srv, fs := newServer(t) + fs.addAgent("acme-agent", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) + seedUser(fs) + fs.addUser("other@beta.com", "correct horse battery", UserRecord{ + ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd", + FullName: "Bo", Role: "manager", Active: true, + }) + + rr := putFace(t, srv, "acme-agent", jpegBytes(64)) + var out struct { + Key string `json:"key"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &out) + + // An image key travels in API responses, so a caller who kept one - or + // guessed one - must get nothing rather than somebody else's customer. + beta := login(t, srv, "other@beta.com", "correct horse battery") + if rec := do(t, srv, "GET", faceURL(out.Key), beta.Token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("another tenant read a stored face, got %d", rec.Code) + } + acme := login(t, srv, "manager@acme.com", "correct horse battery") + if rec := do(t, srv, "GET", faceURL(out.Key), acme.Token, nil); rec.Code != http.StatusOK { + t.Fatalf("the owning tenant could not read its own face, got %d", rec.Code) + } +} + +// The customer record has to work on a deployment with no bucket too - it is +// the screen staff use to recognise the person in front of them. +func TestTheCustomerPhotoWorksWithNoObjectStorage(t *testing.T) { + srv, fs := newServer(t) + srv.Blob = nil + fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) + seedUser(fs) + + rr := putFace(t, srv, "agent-token", jpegBytes(64)) + var up struct { + Key string `json:"key"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &up) + const visitor = "44444444-4444-4444-8444-444444444444" + fs.imageKeys[visitor] = up.Key + + sess := login(t, srv, "manager@acme.com", "correct horse battery") + rec := do(t, srv, "GET", "/api/visitors/"+visitor+"/image", sess.Token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("customer photo: %d %s", rec.Code, rec.Body.String()) + } + var img Image + if err := json.Unmarshal(rec.Body.Bytes(), &img); err != nil { + t.Fatal(err) + } + if !img.Available || !img.Auth { + t.Fatalf("want an available image that needs the session, got %+v", img) + } + // The storage key names a tenant's prefix and must never be what a client + // receives, on either route. + if strings.Contains(rec.Body.String(), "db:") { + t.Errorf("the storage key leaked: %s", rec.Body.String()) + } + // Reading a face is worth an audit row wherever the LINK is handed out. + // Recorded here rather than at the byte fetch, because the bucket route's + // bytes never touch this server and the two must be counted the same way. + if !audited(fs, "image.view") { + t.Error("reading a customer photo left no audit row") + } +} + +func audited(fs *fakeStore, action string) bool { + fs.mu.Lock() + defer fs.mu.Unlock() + for _, a := range fs.audits { + if a.Action == action { + return true + } + } + return false +} + +// Erasure has to destroy an image this server holds, not only one in a bucket. +// A face image that survives an erasure request is the one outcome that +// endpoint must never produce. +func TestErasureDestroysAStoredFace(t *testing.T) { + srv, fs := newServer(t) + srv.Blob = nil + fs.addAgent("agent-token", AgentPrincipal{ClientID: "client-acme", SiteID: "site-1"}) + seedUser(fs) + + rr := putFace(t, srv, "agent-token", jpegBytes(64)) + var up struct { + Key string `json:"key"` + } + _ = json.Unmarshal(rr.Body.Bytes(), &up) + const visitor = "55555555-5555-4555-8555-555555555555" + fs.imageKeys[visitor] = up.Key + + sess := login(t, srv, "manager@acme.com", "correct horse battery") + if rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil); rec.Code != http.StatusNoContent { + t.Fatalf("erase: %d %s", rec.Code, rec.Body.String()) + } + if rec := do(t, srv, "GET", faceURL(up.Key), sess.Token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("the face survived erasure, got %d", rec.Code) + } +} + +// If the image cannot be destroyed, NOTHING is erased and the caller is told. +// Reporting a legal request as honoured when it was not is the failure this +// path exists to prevent. +func TestAFailedFaceDeleteAbortsTheWholeErasure(t *testing.T) { + srv, fs := newServer(t) + srv.Blob = nil + seedUser(fs) + const visitor = "66666666-6666-4666-8666-666666666666" + fs.imageKeys[visitor] = "db:66666666-6666-4666-8666-666666666666" + fs.faceDeleteErr = errors.New("storage is down") + + sess := login(t, srv, "manager@acme.com", "correct horse battery") + rec := do(t, srv, "DELETE", "/api/visitors/"+visitor, sess.Token, nil) + if rec.Code != http.StatusBadGateway { + t.Fatalf("want 502 and nothing erased, got %d: %s", rec.Code, rec.Body.String()) + } + if len(fs.forgotten) != 0 { + t.Fatalf("the record was erased even though the photo could not be: %v", fs.forgotten) + } +} diff --git a/server/internal/api/fake_test.go b/server/internal/api/fake_test.go index f7951d2..7c0a7ae 100644 --- a/server/internal/api/fake_test.go +++ b/server/internal/api/fake_test.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "net/http" + "strings" "sync" "time" @@ -70,6 +71,14 @@ type fakeStore struct { lastCheckKind string lastCheckSeconds int + // Invitations, and the faces this server holds itself. + invites map[string]*fakeInvite // by code hash hex + faces map[string][]byte // "client/id" + lastFaceClient string + lastFaceSite string + deletedFaces []string + faceDeleteErr error + cameras []Camera agentCameras []AgentCamera lastCameraReport AgentCameraReport @@ -97,6 +106,7 @@ type fakeSession struct { id string p auth.Principal accessExp, refreshExp time.Time + device string revoked bool } @@ -150,7 +160,11 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error { f.mu.Lock() defer f.mu.Unlock() f.nextID++ - id := "sess-" + itoa(f.nextID) + // uuid-SHAPED, because the handlers validate the shape of an id before + // spending a database round trip on it. A fake that mints "sess-1" would + // make every id-addressed session route 404 in tests and pass in + // production, which is the wrong way round. + id := fmt.Sprintf("00000000-0000-4000-8000-%012d", f.nextID) var rec UserRecord for _, u := range f.users { if u.ID == n.UserID { @@ -165,6 +179,7 @@ func (f *fakeStore) CreateSession(_ context.Context, n NewSession) error { FullName: rec.FullName, Role: rec.Role, }, accessExp: n.AccessExpiry, refreshExp: n.RefreshExp, + device: n.Device, } f.sessions[id] = s f.byAccess[hex.EncodeToString(n.AccessHash)] = id @@ -673,3 +688,228 @@ func (f *fakeStore) addCameraRef(id, client, site, engineID string) { } type cameraRef struct{ client, site, engineID string } + +// ==================================== team, invitations, sessions, faces ==== +// +// These behave rather than merely satisfy the interface: single use, tenant +// scoping and "the role comes from the invitation" are the properties the +// handlers are trusted for, so a fake that always says yes would make the tests +// that check them meaningless. + +type fakeInvite struct { + id, clientID, email, fullName, role string + expires time.Time + used, revoked bool +} + +func (f *fakeStore) CreateInvitation(_ context.Context, in NewInvitation) (Invitation, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.invites == nil { + f.invites = map[string]*fakeInvite{} + } + f.nextID++ + id := fmt.Sprintf("00000000-0000-4000-9000-%012d", f.nextID) + f.invites[hex.EncodeToString(in.CodeHash)] = &fakeInvite{ + id: id, clientID: in.ClientID, email: in.Email, + fullName: in.FullName, role: in.Role, expires: in.ExpiresAt, + } + return Invitation{ + ID: id, Email: in.Email, FullName: in.FullName, Role: in.Role, + ExpiresAt: in.ExpiresAt.UTC().Format(time.RFC3339), + CreatedAt: time.Now().UTC().Format(time.RFC3339), + }, nil +} + +func (f *fakeStore) PendingInvitations(_ context.Context, clientID string) ([]Invitation, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []Invitation + for _, v := range f.invites { + if v.clientID != clientID || v.used || v.revoked { + continue + } + out = append(out, Invitation{ID: v.id, Email: v.email, + FullName: v.fullName, Role: v.role, + ExpiresAt: v.expires.UTC().Format(time.RFC3339)}) + } + return out, nil +} + +func (f *fakeStore) RevokeInvitation(_ context.Context, clientID, id string) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, v := range f.invites { + if v.id == id && v.clientID == clientID && !v.used && !v.revoked { + v.revoked = true + return nil + } + } + return errors.New("no such pending invitation") +} + +func (f *fakeStore) InvitationByCode(_ context.Context, hash []byte) (InvitationPreview, error) { + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.invites[hex.EncodeToString(hash)] + if !ok || v.used || v.revoked || time.Now().After(v.expires) { + return InvitationPreview{}, errors.New("that invitation is not valid") + } + return InvitationPreview{Client: "Fake Co", Email: v.email, + FullName: v.fullName, Role: v.role}, nil +} + +func (f *fakeStore) RedeemInvitation(_ context.Context, hash []byte, + fullName, passwordHash string) (UserRecord, error) { + + f.mu.Lock() + defer f.mu.Unlock() + v, ok := f.invites[hex.EncodeToString(hash)] + if !ok || v.used || v.revoked || time.Now().After(v.expires) { + return UserRecord{}, errors.New("that invitation is not valid") + } + if _, taken := f.users[v.email]; taken { + return UserRecord{}, errors.New("app_users_email_idx") + } + // Marked spent BEFORE the account exists, mirroring the real store's one + // transaction: a test that redeems the same code twice must get one user. + v.used = true + f.nextID++ + rec := UserRecord{ + ID: fmt.Sprintf("00000000-0000-4000-a000-%012d", f.nextID), + // From the INVITATION, never from the request - which is the property + // worth having a fake at all for. + ClientID: v.clientID, ClientName: "Fake Co", Email: v.email, + FullName: fullName, Role: v.role, Active: true, Found: true, + PasswordHash: passwordHash, + } + if rec.FullName == "" { + rec.FullName = v.fullName + } + f.users[v.email] = rec + return rec, nil +} + +func (f *fakeStore) Team(_ context.Context, clientID string) ([]TeamMember, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []TeamMember + for _, u := range f.users { + if u.ClientID != clientID { + continue + } + out = append(out, TeamMember{ID: u.ID, Email: u.Email, + FullName: u.FullName, Role: u.Role, Active: u.Active}) + } + return out, nil +} + +func (f *fakeStore) UpdateTeamMember(_ context.Context, clientID, userID string, + up TeamUpdate) (TeamMember, error) { + + f.mu.Lock() + defer f.mu.Unlock() + for email, u := range f.users { + if u.ID != userID || u.ClientID != clientID { + continue + } + if up.Role != nil { + u.Role = *up.Role + } + if up.Active != nil { + u.Active = *up.Active + if !u.Active { + // The real store revokes in the same transaction; the fake + // does it here so a test can prove "they have left" actually + // signs them out rather than waiting twelve hours. + for _, s := range f.sessions { + if s.p.UserID == userID { + s.revoked = true + } + } + } + } + f.users[email] = u + return TeamMember{ID: u.ID, Email: u.Email, FullName: u.FullName, + Role: u.Role, Active: u.Active}, nil + } + return TeamMember{}, errors.New("no such team member") +} + +func (f *fakeStore) UserSessions(_ context.Context, userID string) ([]DeviceSession, error) { + f.mu.Lock() + defer f.mu.Unlock() + var out []DeviceSession + for _, s := range f.sessions { + if s.p.UserID != userID || s.revoked { + continue + } + out = append(out, DeviceSession{ID: s.id, Device: s.device, + ExpiresAt: s.refreshExp.UTC().Format(time.RFC3339)}) + } + return out, nil +} + +func (f *fakeStore) RevokeUserSession(_ context.Context, userID, sessionID string) error { + f.mu.Lock() + defer f.mu.Unlock() + s, ok := f.sessions[sessionID] + // Scoped by user id, exactly as the real UPDATE is: a session id travels in + // a list and is not a secret, so it must not sign anybody else out. + if !ok || s.p.UserID != userID || s.revoked { + return errors.New("no such session") + } + s.revoked = true + return nil +} + +func (f *fakeStore) RevokeOtherSessions(_ context.Context, userID, keep string) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + n := 0 + for _, s := range f.sessions { + if s.p.UserID == userID && s.id != keep && !s.revoked { + s.revoked = true + n++ + } + } + return n, nil +} + +func (f *fakeStore) PutVisitFace(_ context.Context, clientID, siteID string, + jpeg []byte) (string, error) { + + f.mu.Lock() + defer f.mu.Unlock() + if f.faces == nil { + f.faces = map[string][]byte{} + } + f.nextID++ + id := fmt.Sprintf("00000000-0000-4000-b000-%012d", f.nextID) + f.faces[clientID+"/"+id] = jpeg + f.lastFaceClient, f.lastFaceSite = clientID, siteID + return "db:" + id, nil +} + +func (f *fakeStore) VisitFace(_ context.Context, clientID, key string) ([]byte, error) { + f.mu.Lock() + defer f.mu.Unlock() + img, ok := f.faces[clientID+"/"+strings.TrimPrefix(key, "db:")] + if !ok { + return nil, errors.New("no such face image") + } + return img, nil +} + +func (f *fakeStore) DeleteVisitFaces(_ context.Context, clientID string, keys []string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.faceDeleteErr != nil { + return f.faceDeleteErr + } + for _, k := range keys { + delete(f.faces, clientID+"/"+strings.TrimPrefix(k, "db:")) + f.deletedFaces = append(f.deletedFaces, k) + } + return nil +} diff --git a/server/internal/api/handlers_arrivals.go b/server/internal/api/handlers_arrivals.go index 728f063..9cae58a 100644 --- a/server/internal/api/handlers_arrivals.go +++ b/server/internal/api/handlers_arrivals.go @@ -122,27 +122,9 @@ func (s *Server) attachImages(r *http.Request, rows []Arrival) { for i := range rows { key := rows[i].ImageKey rows[i].ImageKey = "" - switch { - case s.Blob == nil: - rows[i].Image.Reason = "This system is not storing customer photos." - case key == "": - rows[i].Image.Reason = "No photo was captured for this visit." - default: - url, err := s.Blob.PresignGet(key, viewTTL) - if err != nil { - // Log it, but never fail the feed over a picture. The visit is - // the number the customer pays for; the photo is decoration on - // top of it. This is the same rule the agent follows when an - // upload fails. - s.logf("ERROR presign arrival image: %v", err) - rows[i].Image.Reason = "That photo could not be loaded." - continue - } - rows[i].Image = Image{Available: true, URL: url, - ExpiresIn: int(viewTTL.Seconds())} - if rows[i].VisitorID != "" { - seen = append(seen, rows[i].VisitorID) - } + rows[i].Image = s.imageFor(key) + if rows[i].Image.Available && rows[i].VisitorID != "" { + seen = append(seen, rows[i].VisitorID) } } diff --git a/server/internal/api/handlers_cameras.go b/server/internal/api/handlers_cameras.go index 5f02ef7..4174207 100644 --- a/server/internal/api/handlers_cameras.go +++ b/server/internal/api/handlers_cameras.go @@ -59,6 +59,11 @@ func (s *Server) attachSnapshots(cams []Camera) { Available: true, URL: "/api/cameras/" + cams[i].ID + "/snapshot.jpg", ExpiresIn: int(snapshotTTL.Seconds()), + // Says out loud that this URL needs the session's bearer. + // Clients used to infer it from the URL being relative, which + // is true today and stops being true the first time object + // storage is served from this same host. + Auth: true, } case key == "": cams[i].Snapshot.Reason = "No picture from this camera yet." diff --git a/server/internal/api/handlers_faces.go b/server/internal/api/handlers_faces.go new file mode 100644 index 0000000..8a08deb --- /dev/null +++ b/server/internal/api/handlers_faces.go @@ -0,0 +1,186 @@ +package api + +import ( + "errors" + "fmt" + "io" + "net/http" + "strconv" + "time" +) + +// Face images held by this server, for a deployment with no object storage. +// +// Where a bucket IS configured nothing here is used: the agent keeps asking for +// a presigned URL and the reader keeps getting a signed link, which never puts +// a photograph through this process at all and is the right route at estate +// scale. This is the fallback that stops "no S3 account" from meaning "no +// customer photo, ever", which is what every local install and every +// self-hosted customer got - including on the mobile arrivals feed, whose whole +// job is to put a face in front of somebody. +// +// Migration 011 carries the argument for why this is bounded and therefore safe +// to keep in Postgres when per-visit images are not: one row survives per +// customer, so it grows with the customer base and not with footfall. + +// maxFaceBytes caps one upload. The engine writes ~20 KB crops; 2 MB is +// generous for a large one and small enough that a misbehaving agent cannot use +// this as free storage. +const maxFaceBytes = 2 << 20 + +// faceMaxAge is how long a client may reuse a face it has already fetched. +// The image for a given key never changes - a newer view gets a new key - so +// this is only bounded to keep a signed-out device from holding one for ever. +const faceMaxAge = 5 * time.Minute + +// handlePutFace takes one face crop from a shop PC. +// +// The client and site come from the agent's own credential and are never read +// off the request, so a shop PC physically cannot file an image under another +// tenant - the same rule every other agent-authenticated write here follows. +// +// The response is a KEY, which the agent then puts on the queued visit exactly +// as it does with a bucket object. That symmetry is deliberate: the two storage +// routes differ in one hop and in nothing else, so the ingest path, the read +// path and erasure all stay single implementations. +func (s *Server) handlePutFace(w http.ResponseWriter, r *http.Request, ap AgentPrincipal) { + body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxFaceBytes+1)) + if err != nil || len(body) > maxFaceBytes { + writeErr(w, http.StatusRequestEntityTooLarge, "too_large", + fmt.Sprintf("A face image must be under %d KB.", maxFaceBytes/1024)) + return + } + if len(body) == 0 { + badRequest(w, "the image is empty") + return + } + // Checked against the bytes, never the Content-Type header. This endpoint + // stores what it is handed and serves it back to a browser, so the one + // thing it must not become is a way to park arbitrary content under a URL + // this server will serve. + if !isJPEG(body) { + badRequest(w, "a face image must be a JPEG") + return + } + + key, err := s.Store.PutVisitFace(r.Context(), ap.ClientID, ap.SiteID, body) + if err != nil { + s.serverError(w, "store face", err) + return + } + writeJSON(w, http.StatusCreated, map[string]any{"key": key}) +} + +// handleGetFace serves one back to a signed-in person. +// +// Session-authenticated rather than a signed link, and that is the same call +// camera snapshots already made: there is no third party to delegate to - the +// bytes are in our own database - and minting an unauthenticated URL so that a +// plain could load it would add a way to reach a photograph of +// somebody's customer with no session at all. +// +// The consequence is a real one and clients must handle it: a browser +// cannot send an Authorization header, so the web app fetches this and hands +// over an object URL. A mobile image view can attach the header directly. The +// `auth` flag on every Image says which kind of URL it is holding. +// +// No audit row is written here. Every read of a face is recorded where the LINK +// is handed out - the arrivals page writes one row per page, the customer +// record one per look - and the two paths must not disagree about what counts +// as a read. Recording the byte fetch as well would double-count the DB +// deployment and leave the bucket deployment, whose bytes never touch this +// server, counted once. +func (s *Server) handleGetFace(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + img, err := s.Store.VisitFace(r.Context(), p.ClientID, faceKey(r.PathValue("id"))) + if err != nil { + writeErr(w, http.StatusNotFound, "no_image", "There is no photo here.") + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Header().Set("Content-Length", strconv.Itoa(len(img))) + w.Header().Set("Cache-Control", "private, max-age="+ + strconv.Itoa(int(faceMaxAge.Seconds()))) + // A photograph of a customer must not travel to a third party in a Referer + // header if this URL is ever rendered inside a page that links out. + w.Header().Set("Referrer-Policy", "no-referrer") + if _, err := w.Write(img); err != nil && !errors.Is(err, http.ErrHandlerTimeout) { + s.logf("WARN write face: %v", err) + } +} + +// faceKey rebuilds the stored key from the id in the path. +// +// The route is `/api/faces/{id}.jpg` so a client can hand the URL to an image +// view that decides what to do by extension, and the `.jpg` is presentation +// rather than part of the key. +func faceKey(id string) string { + if n := len(id); n > 4 && id[n-4:] == ".jpg" { + id = id[:n-4] + } + return dbKeyPrefix + id +} + +// dbKeyPrefix mirrors store.DBKeyPrefix. Duplicated rather than imported +// because this package must not depend on the concrete store - the whole point +// of the Store interface - and it is a wire constant that changing on one side +// alone would break loudly and immediately in the tests either way. +const dbKeyPrefix = "db:" + +// isDBKey reports whether an image key names a row here rather than an object +// in a bucket. +func isDBKey(key string) bool { + return len(key) > len(dbKeyPrefix) && key[:len(dbKeyPrefix)] == dbKeyPrefix +} + +// faceURL is the path a client fetches for a stored face. +func faceURL(key string) string { + return "/api/faces/" + key[len(dbKeyPrefix):] + ".jpg" +} + +// imageFor turns one stored image key into the Image a client receives. +// +// ONE function decides this, for every surface: the arrivals feed, the live +// stream, the customer record. There are now two places an image can live and +// four distinct reasons there may not be one, and the failure this avoids is +// the one the shops screen already hit once - two surfaces computing the same +// fact separately and disagreeing about it in front of a user. +// +// A missing photo is DATA, not an error. Images are off by default across the +// whole product, so on most deployments every arrival legitimately has none; a +// client that renders a failure state would show a screen of red for a system +// working exactly as configured. The two absences are told apart because a shop +// can act on one and not the other. +func (s *Server) imageFor(key string) Image { + switch { + case key == "" && s.Blob == nil: + return Image{Reason: "This system is not storing customer photos."} + case key == "": + return Image{Reason: "No photo was captured for this visit."} + + case isDBKey(key): + // Held by this server. A relative URL that needs the caller's session - + // see handleGetFace for why it is not a signed link - so it carries no + // expiry: it is valid for exactly as long as the session is. + return Image{Available: true, URL: faceURL(key), Auth: true} + + case s.Blob == nil: + // A bucket key on a server with no bucket. Only reachable if object + // storage was configured once and has since been removed, and it is + // worth its own sentence: the photo exists somewhere and this + // deployment can no longer reach it, which is a configuration problem + // rather than a customer with no picture. + return Image{Reason: "This server can no longer reach its image storage."} + + default: + url, err := s.Blob.PresignGet(key, viewTTL) + if err != nil { + // Logged, never fatal. The visit is the number the customer pays + // for; the photo is decoration on top of it. Same rule the agent + // follows when an upload fails. + s.logf("ERROR presign image: %v", err) + return Image{Reason: "That photo could not be loaded."} + } + return Image{Available: true, URL: url, ExpiresIn: int(viewTTL.Seconds())} + } +} diff --git a/server/internal/api/handlers_images.go b/server/internal/api/handlers_images.go index 690bbb8..fa58f41 100644 --- a/server/internal/api/handlers_images.go +++ b/server/internal/api/handlers_images.go @@ -91,31 +91,36 @@ func (s *Server) handleVisitorImage(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusNotFound, "not_found", "That customer no longer exists.") return } - if s.Blob == nil { - writeErr(w, http.StatusNotFound, "images_disabled", - "This server does not store images.") - return - } key, err := s.Store.VisitorImageKey(r.Context(), p.ClientID, id) - if err != nil || key == "" { - writeErr(w, http.StatusNotFound, "no_image", - "There is no photo for this customer.") - return - } - url, err := s.Blob.PresignGet(key, viewTTL) if err != nil { - s.serverError(w, "presign read", err) + key = "" + } + // The same function every other surface uses. Two ways to answer "where is + // this person's photo" would eventually answer differently, and the one + // that mattered would be whichever the customer was looking at. + img := s.imageFor(key) + if !img.Available { + // Absence, with the reason. `no_image` and `images_disabled` are + // separate codes because the desktop and mobile clients act on them + // differently: one is a customer with no picture yet, the other is a + // deployment that stores none and should stop asking. + code := "no_image" + if key == "" && s.Blob == nil { + code = "images_disabled" + } + writeErr(w, http.StatusNotFound, code, img.Reason) return } // Every read of a face image is worth a row. If a client asks "who looked // at my customers", an audit trail is the only answer that is not a guess. + // Recorded HERE, where the link is handed out, for both storage routes - + // the bucket's bytes never touch this server, so the fetch itself is not a + // place both paths could be counted. s.Store.Audit(r.Context(), AuditEntry{ ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", Action: "image.view", Entity: "visitor", EntityID: id, }) - writeJSON(w, http.StatusOK, map[string]any{ - "url": url, "expires_in": int(viewTTL.Seconds()), - }) + writeJSON(w, http.StatusOK, img) } // handleForgetVisitor is the erasure path. @@ -146,8 +151,21 @@ func (s *Server) handleForgetVisitor(w http.ResponseWriter, r *http.Request) { s.serverError(w, "list images for erasure", err) return } + // Images this server holds itself. Deleted before the row, for the same + // reason the bucket objects are: if the database commits first and this + // fails, the keys are gone and nothing knows which images to remove. + if err := s.Store.DeleteVisitFaces(r.Context(), p.ClientID, keys); err != nil { + s.logf("ERROR erasure %s: cannot delete stored faces: %v", id, err) + writeErr(w, http.StatusBadGateway, "storage_error", + "The photo could not be deleted, so nothing was erased. "+ + "Please try again.") + return + } if s.Blob != nil { for _, key := range keys { + if isDBKey(key) { + continue // already gone, above + } if err := s.Blob.Delete(r.Context(), key); err != nil { // Refuse the whole request. Reporting an erasure as done while // a face image is still in the bucket is the one outcome this diff --git a/server/internal/api/handlers_sessions.go b/server/internal/api/handlers_sessions.go new file mode 100644 index 0000000..0def5ea --- /dev/null +++ b/server/internal/api/handlers_sessions.go @@ -0,0 +1,80 @@ +package api + +import ( + "net/http" +) + +// Which devices are signed in, and signing one of them out. +// +// This is the point of opaque tokens in a table rather than JWTs, and until now +// the product had the cost of that choice without the benefit. The argument +// recorded for it was that this system puts customer data on shop-floor PCs and +// staff phones that get lost, resold and shared between people, so "log that +// device out, now" has to actually work - and there was no endpoint that could +// list what was signed in, let alone stop one. +// +// It matters most on mobile, which is why it arrives with it: a phone is the +// device most likely to leave the building in somebody's pocket. + +func (s *Server) handleSessions(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + rows, err := s.Store.UserSessions(r.Context(), p.UserID) + if err != nil { + s.serverError(w, "list sessions", err) + return + } + if rows == nil { + rows = []DeviceSession{} + } + // Marked here rather than in SQL: which session is "this one" is a property + // of the request, and the store has no business knowing about requests. + for i := range rows { + rows[i].Current = rows[i].ID == p.SessionID + } + writeJSON(w, http.StatusOK, rows) +} + +// handleRevokeSession signs one device out. +// +// A person may only revoke their OWN sessions - the store scopes the update by +// user id, so a session id, which is not a secret and travels in the list +// above, cannot be used to sign somebody else out. Removing a colleague's +// access is a different question with a different answer: deactivate them +// through the team endpoint, which revokes every session they have. +func (s *Server) handleRevokeSession(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + id := r.PathValue("id") + if !looksLikeUUID(id) { + writeErr(w, http.StatusNotFound, "not_found", "No such device.") + return + } + if err := s.Store.RevokeUserSession(r.Context(), p.UserID, id); err != nil { + writeErr(w, http.StatusNotFound, "not_found", "No such device.") + return + } + s.Store.Audit(r.Context(), AuditEntry{ + ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", + Action: "auth.session.revoke", Entity: "session", EntityID: id, + }) + w.WriteHeader(http.StatusNoContent) +} + +// handleRevokeOtherSessions is "sign out everywhere else". +// +// It deliberately keeps the caller's own session. Somebody who has just lost a +// phone should not also be signed out of the device in their hand, in the +// middle of dealing with it. +func (s *Server) handleRevokeOtherSessions(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + n, err := s.Store.RevokeOtherSessions(r.Context(), p.UserID, p.SessionID) + if err != nil { + s.serverError(w, "revoke sessions", err) + return + } + s.Store.Audit(r.Context(), AuditEntry{ + ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", + Action: "auth.session.revoke_others", Entity: "session", + Detail: map[string]any{"count": n}, + }) + writeJSON(w, http.StatusOK, map[string]any{"signed_out": n}) +} diff --git a/server/internal/api/handlers_team.go b/server/internal/api/handlers_team.go new file mode 100644 index 0000000..6965e8e --- /dev/null +++ b/server/internal/api/handlers_team.go @@ -0,0 +1,390 @@ +package api + +import ( + "net/http" + "strings" + "time" + + "github.com/loyaly/behavision-server/internal/auth" +) + +// Adding people to a company: invitations, registration, and the team list. +// +// Registration is by INVITATION, and that is the same decision handlers_admin.go +// records for creating a company: an endpoint a stranger can call to create an +// account is a far larger thing to secure than one reachable only through +// somebody who already has one. What was missing was not the openness - it was +// that a tenant could not add a SECOND person at all except by somebody with a +// shell on the server running `provision user`. A shop with an owner and four +// staff either shared one password between five people or raised a ticket per +// person, and a phone app for shop-floor staff could not exist while there was +// only ever one account to sign in as. +// +// So: a manager mints a code, hands it over, and the holder chooses their own +// password. The code carries the address and the role; the request carries only +// the password and a name. That split is load-bearing and is why this is not +// simply "create a user with these fields" - see handleRegister. + +const ( + // Long enough to reach somebody who is not at work today, short enough that + // a code left in a chat thread is worthless before anyone scrolls back to + // it. An expired invitation costs one click to reissue. + invitationTTL = 7 * 24 * time.Hour + maxInvitation = 30 * 24 * time.Hour +) + +// handleInvite mints one invitation. +// +// Manager and above. Not staff: the holder of a code gets an account inside +// this company, so it is a credential, not a convenience. +func (s *Server) handleInvite(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + if !p.CanManageSites() || p.ClientID == "" { + writeErr(w, http.StatusForbidden, "forbidden", + "Your account cannot invite people to this company.") + return + } + + var body struct { + Email string `json:"email"` + FullName string `json:"full_name"` + Role string `json:"role"` + Days int `json:"expires_in_days"` + } + if err := decode(w, r, &body); err != nil { + badRequest(w, err.Error()) + return + } + email := auth.NormalizeEmail(body.Email) + if email == "" || !strings.Contains(email, "@") { + badRequest(w, "an email address is required - it is what they will sign in with") + return + } + role := strings.ToLower(trim(body.Role)) + if role == "" { + role = "staff" + } + // 'admin' is absent on purpose. A platform administrator is defined by + // having no company at all, so an invitation could never mint a real one - + // what it could do is create the tenant-scoped row with role='admin' that + // adminOnly exists to reject, and a role nothing can use is a trap rather + // than a feature. + switch role { + case "owner", "manager", "staff": + default: + badRequest(w, "role must be owner, manager or staff") + return + } + // Only an owner may create another owner. A manager promoting somebody past + // themselves is an escalation, and it is the one shape of this endpoint + // that would matter if a manager account were ever taken over. + if role == "owner" && p.Role != "owner" && p.Role != "admin" { + writeErr(w, http.StatusForbidden, "forbidden", + "Only an owner can invite another owner.") + return + } + + ttl := invitationTTL + if body.Days > 0 { + ttl = time.Duration(body.Days) * 24 * time.Hour + if ttl > maxInvitation { + ttl = maxInvitation + } + } + + code, err := auth.NewEnrolmentCode() + if err != nil { + s.serverError(w, "mint invitation", err) + return + } + inv, err := s.Store.CreateInvitation(r.Context(), NewInvitation{ + ClientID: p.ClientID, + Email: email, + FullName: clip(trim(body.FullName), 200), + Role: role, + CodeHash: auth.HashToken(auth.NormalizeCode(code)), + InvitedBy: p.UserID, + ExpiresAt: s.now().Add(ttl), + }) + if err != nil { + s.serverError(w, "create invitation", err) + return + } + // The plaintext exists here and in this response, and nowhere else. Like + // every other secret this system mints, it is shown once: one a support + // engineer can look up later is one anybody with support access can redeem. + inv.Code = code + + s.Store.Audit(r.Context(), AuditEntry{ + ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", + Action: "team.invite", Entity: "invitation", EntityID: inv.ID, + Detail: map[string]any{"email": email, "role": role}, + }) + writeJSON(w, http.StatusCreated, inv) +} + +func (s *Server) handleInvitations(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + if !p.CanManageSites() || p.ClientID == "" { + writeErr(w, http.StatusForbidden, "forbidden", + "Your account cannot see this company's invitations.") + return + } + rows, err := s.Store.PendingInvitations(r.Context(), p.ClientID) + if err != nil { + s.serverError(w, "list invitations", err) + return + } + if rows == nil { + rows = []Invitation{} + } + writeJSON(w, http.StatusOK, rows) +} + +func (s *Server) handleRevokeInvitation(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + if !p.CanManageSites() || p.ClientID == "" { + writeErr(w, http.StatusForbidden, "forbidden", + "Your account cannot withdraw invitations.") + return + } + id := r.PathValue("id") + if !looksLikeUUID(id) { + writeErr(w, http.StatusNotFound, "not_found", "No such invitation.") + return + } + if err := s.Store.RevokeInvitation(r.Context(), p.ClientID, id); err != nil { + // Already used or already withdrawn. Reported rather than swallowed: + // "I cancelled it" and "somebody had already joined with it" need + // opposite next steps from whoever pressed the button. + writeErr(w, http.StatusNotFound, "not_found", + "That invitation is no longer pending.") + return + } + s.Store.Audit(r.Context(), AuditEntry{ + ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", + Action: "team.invite.revoke", Entity: "invitation", EntityID: id, + }) + w.WriteHeader(http.StatusNoContent) +} + +// handleInvitationPreview lets a client show what a code is for before asking +// somebody to choose a password. +// +// Unauthenticated, because the holder has no account yet - that is the whole +// point - and it discloses only what the code itself already asserts: the +// company, the address it was issued for, and the role. Unknown, expired, spent +// and revoked are one identical answer, exactly as enrolment already does: +// telling them apart only helps somebody guessing codes, and the holder's next +// step is the same in all four cases. +func (s *Server) handleInvitationPreview(w http.ResponseWriter, r *http.Request) { + code := auth.NormalizeCode(r.URL.Query().Get("code")) + if code == "" { + badRequest(w, "a code is required") + return + } + prev, err := s.Store.InvitationByCode(r.Context(), auth.HashToken(code)) + if err != nil { + writeErr(w, http.StatusNotFound, "invalid_code", + "That invitation code is not valid. Ask for a new one.") + return + } + writeJSON(w, http.StatusOK, prev) +} + +// handleRegister turns a code into an account and signs the person in. +// +// Unauthenticated for the same reason `POST /api/agent/enrol` is: whoever is +// doing this has no account yet, and requiring one first would mean shipping a +// password to everybody who needs one. +// +// The email and the role come from the INVITATION, never from this body. A code +// forwarded to a colleague must not become an account for them, and a staff +// invitation must not be redeemed as an owner - which is exactly what a +// caller-supplied role would allow. The only things the request decides are the +// password and the display name. +// +// It returns a Session, identical in shape to login. A new member's next screen +// is the app, not a sign-in form they have to fill in with the password they +// chose four seconds ago. +func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { + var body struct { + Code string `json:"code"` + FullName string `json:"full_name"` + Password string `json:"password"` + Device string `json:"device"` + } + if err := decode(w, r, &body); err != nil { + badRequest(w, err.Error()) + return + } + code := auth.NormalizeCode(body.Code) + if code == "" { + badRequest(w, "an invitation code is required") + return + } + + // Throttled on the code, by IP. Redeeming is the one unauthenticated write + // in this package that creates a row, so an unbounded one is a way to grind + // through the code space and to fill a table while doing it. + _, perIP := s.throttles() + ipKey := clientIP(r) + if !perIP.Allow(ipKey) { + writeErr(w, http.StatusTooManyRequests, "too_many_attempts", + "Too many attempts. Wait a few minutes and try again.") + return + } + + if err := auth.CheckPasswordPolicy(body.Password); err != nil { + badRequest(w, err.Error()) + return + } + hash, err := auth.HashPassword(body.Password) + if err != nil { + s.serverError(w, "hash password", err) + return + } + + rec, err := s.Store.RedeemInvitation(r.Context(), auth.HashToken(code), + clip(trim(body.FullName), 200), hash) + if err != nil { + perIP.Fail(ipKey) + if msg, ok := conflictMessage(err); ok { + // The address already has an account somewhere on the platform. + // Worth saying plainly: the fix is to sign in, not to try again. + writeErr(w, http.StatusConflict, "conflict", msg) + return + } + writeErr(w, http.StatusNotFound, "invalid_code", + "That invitation code is not valid. Ask for a new one.") + return + } + perIP.Reset(ipKey) + + sess, err := s.mint(r, rec, body.Device) + if err != nil { + // The account exists and the invitation is spent. Say so rather than + // implying nothing happened - the recovery is to sign in, and telling + // them to redeem again would fail forever. + s.logf("ERROR register: created %s but could not start a session: %v", rec.ID, err) + writeErr(w, http.StatusInternalServerError, "server_error", + "Your account was created but we could not sign you in. Please sign in.") + return + } + s.Store.Audit(r.Context(), AuditEntry{ + ClientID: rec.ClientID, ActorID: rec.ID, ActorKind: "user", + Action: "team.register", Entity: "user", EntityID: rec.ID, + Detail: map[string]any{"role": rec.Role, "device": trim(body.Device)}, + }) + s.logf("registered %s (%s) into client %s", rec.Email, rec.Role, rec.ClientID) + writeJSON(w, http.StatusCreated, sess) +} + +func (s *Server) handleTeam(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + if p.ClientID == "" { + writeErr(w, http.StatusForbidden, "forbidden", + "This account does not belong to a company.") + return + } + rows, err := s.Store.Team(r.Context(), p.ClientID) + if err != nil { + s.serverError(w, "list team", err) + return + } + if rows == nil { + rows = []TeamMember{} + } + writeJSON(w, http.StatusOK, rows) +} + +// handleUpdateTeamMember changes a role, or turns an account off. +// +// Deactivating is the "they have left" button, and the store revokes their +// sessions in the same transaction: an access token lives twelve hours, so +// without that, removing somebody's access would remove it sometime tomorrow. +func (s *Server) handleUpdateTeamMember(w http.ResponseWriter, r *http.Request) { + p := PrincipalFrom(r.Context()) + if !p.CanManageSites() || p.ClientID == "" { + writeErr(w, http.StatusForbidden, "forbidden", + "Your account cannot change who works here.") + return + } + id := r.PathValue("id") + if !looksLikeUUID(id) { + writeErr(w, http.StatusNotFound, "not_found", "No such team member.") + return + } + + var up TeamUpdate + if err := decode(w, r, &up); err != nil { + badRequest(w, err.Error()) + return + } + if up.Role == nil && up.Active == nil { + badRequest(w, "nothing to change - send a role, an active flag, or both") + return + } + if up.Role != nil { + role := strings.ToLower(trim(*up.Role)) + switch role { + case "owner", "manager", "staff": + default: + badRequest(w, "role must be owner, manager or staff") + return + } + if role == "owner" && p.Role != "owner" && p.Role != "admin" { + writeErr(w, http.StatusForbidden, "forbidden", + "Only an owner can make somebody else an owner.") + return + } + up.Role = &role + } + + // The company must keep an owner. Losing the last one leaves a tenant + // nobody can administer, and the only way back is a shell on the server - + // which is the thing this whole surface exists to stop needing. + demoting := up.Role != nil && *up.Role != "owner" + disabling := up.Active != nil && !*up.Active + if demoting || disabling { + if last, err := s.lastOwner(r, id); err != nil { + s.serverError(w, "count owners", err) + return + } else if last { + writeErr(w, http.StatusConflict, "last_owner", + "This is the company's only owner. Make somebody else an owner first.") + return + } + } + + m, err := s.Store.UpdateTeamMember(r.Context(), p.ClientID, id, up) + if err != nil { + writeErr(w, http.StatusNotFound, "not_found", "No such team member.") + return + } + s.Store.Audit(r.Context(), AuditEntry{ + ClientID: p.ClientID, ActorID: p.UserID, ActorKind: "user", + Action: "team.update", Entity: "user", EntityID: id, + Detail: map[string]any{"role": m.Role, "active": m.Active}, + }) + writeJSON(w, http.StatusOK, m) +} + +// lastOwner reports whether the named member is the only active owner left. +func (s *Server) lastOwner(r *http.Request, userID string) (bool, error) { + p := PrincipalFrom(r.Context()) + rows, err := s.Store.Team(r.Context(), p.ClientID) + if err != nil { + return false, err + } + owners, isOwner := 0, false + for _, m := range rows { + if m.Role == "owner" && m.Active { + owners++ + if m.ID == userID { + isOwner = true + } + } + } + return isOwner && owners == 1, nil +} diff --git a/server/internal/api/sessions_test.go b/server/internal/api/sessions_test.go new file mode 100644 index 0000000..5443d2e --- /dev/null +++ b/server/internal/api/sessions_test.go @@ -0,0 +1,147 @@ +package api + +import ( + "encoding/json" + "net/http" + "testing" +) + +// "Log that device out, now" is the entire argument for keeping sessions in a +// table instead of issuing JWTs. These are the tests that the argument is +// actually cashed in. + +func sessionList(t *testing.T, s *Server, token string) []DeviceSession { + t.Helper() + rec := do(t, s, "GET", "/api/auth/sessions", token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("sessions: %d %s", rec.Code, rec.Body.String()) + } + var out []DeviceSession + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + return out +} + +func loginAs(t *testing.T, s *Server, email, password, device string) Session { + t.Helper() + rec := do(t, s, "POST", "/api/auth/login", "", map[string]string{ + "email": email, "password": password, "device": device}) + if rec.Code != http.StatusOK { + t.Fatalf("login: %d %s", rec.Code, rec.Body.String()) + } + var sess Session + if err := json.Unmarshal(rec.Body.Bytes(), &sess); err != nil { + t.Fatal(err) + } + return sess +} + +func TestAPersonCanSeeAndSignOutTheirOwnDevices(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + phone := loginAs(t, s, "manager@acme.com", "correct horse battery", "Pixel 8") + till := loginAs(t, s, "manager@acme.com", "correct horse battery", "Shop PC") + + rows := sessionList(t, s, till.Token) + if len(rows) != 2 { + t.Fatalf("want two devices, got %d: %+v", len(rows), rows) + } + var phoneID string + for _, r := range rows { + if r.Device == "Pixel 8" { + phoneID = r.ID + } + // The device making the request must be labelled, or somebody signs + // themselves out of the machine in their hand without meaning to. + if r.Device == "Shop PC" && !r.Current { + t.Error("the calling session is not marked current") + } + if r.Device == "Pixel 8" && r.Current { + t.Error("another device is marked current") + } + } + if phoneID == "" { + t.Fatalf("the phone is not in the list: %+v", rows) + } + + if rec := do(t, s, "DELETE", "/api/auth/sessions/"+phoneID, till.Token, nil); rec.Code != http.StatusNoContent { + t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) + } + // Immediately, not when the access token happens to expire. A lost phone is + // the case this exists for and twelve hours is not an answer. + if rec := do(t, s, "GET", "/api/auth/me", phone.Token, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("the revoked device is still signed in, got %d", rec.Code) + } + if rec := do(t, s, "GET", "/api/auth/me", till.Token, nil); rec.Code != http.StatusOK { + t.Fatalf("the calling device was signed out too, got %d", rec.Code) + } +} + +// A session id travels in the list above and is not a secret. The store scopes +// the revoke by user id so one cannot be used to sign a colleague out. +func TestOneUserCannotRevokeAnothersSession(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff") + + victim := loginAs(t, s, "sam@acme.com", "correct horse battery", "Sam's phone") + attacker := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop") + + // The id is obtained the way an attacker would have to: it is not in the + // attacker's own list at all, so this uses the real one directly. + var victimID string + for _, r := range sessionList(t, s, victim.Token) { + victimID = r.ID + } + if rec := do(t, s, "DELETE", "/api/auth/sessions/"+victimID, attacker.Token, nil); rec.Code != http.StatusNotFound { + t.Fatalf("one user revoked another's session, got %d", rec.Code) + } + if rec := do(t, s, "GET", "/api/auth/me", victim.Token, nil); rec.Code != http.StatusOK { + t.Fatal("the victim was signed out by somebody else") + } +} + +// Somebody who has just lost a phone must not also be signed out of the device +// they are holding while they deal with it. +func TestSignOutEverywhereElseKeepsTheCurrentDevice(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + lost := loginAs(t, s, "manager@acme.com", "correct horse battery", "Lost phone") + old := loginAs(t, s, "manager@acme.com", "correct horse battery", "Old tablet") + here := loginAs(t, s, "manager@acme.com", "correct horse battery", "Laptop") + + rec := do(t, s, "POST", "/api/auth/sessions/revoke-others", here.Token, nil) + if rec.Code != http.StatusOK { + t.Fatalf("revoke others: %d %s", rec.Code, rec.Body.String()) + } + var out struct { + SignedOut int `json:"signed_out"` + } + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if out.SignedOut != 2 { + t.Errorf("want two devices signed out, got %d", out.SignedOut) + } + for name, tok := range map[string]string{"lost phone": lost.Token, "old tablet": old.Token} { + if rec := do(t, s, "GET", "/api/auth/me", tok, nil); rec.Code != http.StatusUnauthorized { + t.Errorf("%s is still signed in, got %d", name, rec.Code) + } + } + if rec := do(t, s, "GET", "/api/auth/me", here.Token, nil); rec.Code != http.StatusOK { + t.Fatal("signing out everywhere else signed out this device too") + } +} + +func TestSessionRoutesNeedASession(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + for _, c := range []struct{ method, path string }{ + {"GET", "/api/auth/sessions"}, + {"DELETE", "/api/auth/sessions/" + acmeStaffID}, + {"POST", "/api/auth/sessions/revoke-others"}, + } { + if rec := do(t, s, c.method, c.path, "", nil); rec.Code != http.StatusUnauthorized { + t.Errorf("%s %s: want 401, got %d", c.method, c.path, rec.Code) + } + } +} diff --git a/server/internal/api/team_test.go b/server/internal/api/team_test.go new file mode 100644 index 0000000..7e99f17 --- /dev/null +++ b/server/internal/api/team_test.go @@ -0,0 +1,349 @@ +package api + +import ( + "encoding/json" + "net/http" + "strings" + "testing" +) + +// Registration is by invitation, and almost everything worth testing here is a +// property of that choice: what the code decides versus what the request +// decides, and who is allowed to mint one. + +// Real user ids are uuids and the id-addressed routes check the shape before +// spending a database round trip. A fixture using "u5" would 404 on the guard +// rather than on the rule under test - which is a test that passes for the +// wrong reason, and would keep passing if tenant scoping were removed. +const ( + acmeStaffID = "11111111-1111-4111-8111-111111111111" + acmeOwnerID = "22222222-2222-4222-8222-222222222222" + acmeOtherID = "33333333-3333-4333-8333-333333333333" +) + +func seedMember(fs *fakeStore, id, email, name, role string) { + fs.addUser(email, "correct horse battery", UserRecord{ + ID: id, ClientID: "client-acme", ClientName: "Acme Retail", + FullName: name, Role: role, Active: true, + }) +} + +func invite(t *testing.T, s *Server, token string, body map[string]any) Invitation { + t.Helper() + rec := do(t, s, "POST", "/api/team/invitations", token, body) + if rec.Code != http.StatusCreated { + t.Fatalf("invite: got %d, body %s", rec.Code, rec.Body.String()) + } + var inv Invitation + if err := json.Unmarshal(rec.Body.Bytes(), &inv); err != nil { + t.Fatal(err) + } + return inv +} + +func TestAnInvitationBecomesAnAccountAndASession(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + + inv := invite(t, s, sess.Token, map[string]any{ + "email": "Nikhil@Acme.com", "full_name": "Nikhil", "role": "staff"}) + if inv.Code == "" { + t.Fatal("the response that mints a code must carry it - it is not recoverable later") + } + // Normalised on the way in, so the address somebody types at sign-in is the + // one that was invited whatever case they used. + if inv.Email != "nikhil@acme.com" { + t.Errorf("email should be normalised, got %q", inv.Email) + } + + rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password", "device": "Pixel 8"}) + if rec.Code != http.StatusCreated { + t.Fatalf("register: got %d, body %s", rec.Code, rec.Body.String()) + } + var out Session + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatal(err) + } + // A session, not just a 201. 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. + if out.Token == "" || out.RefreshToken == "" { + t.Fatal("registration should sign the new member in") + } + if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" { + t.Errorf("wrong account: %+v", out.User) + } + if out.User.ClientID != "client-acme" { + t.Errorf("joined the wrong company: %q", out.User.ClientID) + } + if strings.Contains(rec.Body.String(), "$2a$") { + t.Error("password hash leaked into the registration response") + } + + // And the account works. + again := login(t, s, "nikhil@acme.com", "a-good-long-password") + if again.User.ID != out.User.ID { + t.Error("registered account cannot sign in as itself") + } +} + +// The single most important test in this file. A code is forwarded, pasted into +// a chat, screenshotted; if the body could name the address or the role, one +// staff invitation would be an owner account for anybody who saw it. +func TestTheCodeDecidesTheAddressAndTheRoleNotTheRequest(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + inv := invite(t, s, sess.Token, map[string]any{ + "email": "nikhil@acme.com", "role": "staff"}) + + // Unknown fields are refused outright, which is the strongest form of this: + // a client cannot even ask. + rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password", + "email": "attacker@example.com", "role": "owner"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("a body naming an address or a role must be refused, got %d: %s", + rec.Code, rec.Body.String()) + } + + // And redeemed properly, the account is still staff at the invited address. + rec = do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password"}) + var out Session + _ = json.Unmarshal(rec.Body.Bytes(), &out) + if out.User.Email != "nikhil@acme.com" || out.User.Role != "staff" { + t.Fatalf("the invitation did not decide the account: %+v", out.User) + } +} + +func TestAnInvitationIsSingleUse(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + inv := invite(t, s, sess.Token, map[string]any{"email": "one@acme.com"}) + + first := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password"}) + if first.Code != http.StatusCreated { + t.Fatalf("first redemption: %d %s", first.Code, first.Body.String()) + } + second := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "another-long-password"}) + if second.Code == http.StatusCreated { + t.Fatal("a spent invitation created a second account") + } +} + +func TestARevokedInvitationCannotBeRedeemed(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + inv := invite(t, s, sess.Token, map[string]any{"email": "gone@acme.com"}) + + if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, sess.Token, nil); rec.Code != http.StatusNoContent { + t.Fatalf("revoke: %d %s", rec.Code, rec.Body.String()) + } + rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password"}) + if rec.Code == http.StatusCreated { + t.Fatal("a withdrawn invitation still worked") + } +} + +// Unknown, expired, spent and revoked are one answer. The difference only ever +// helps somebody guessing, and the holder's next step is identical in all four. +func TestAnInvalidCodeSaysNothingAboutWhy(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + inv := invite(t, s, sess.Token, map[string]any{"email": "used@acme.com"}) + _ = do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password"}) + + spent := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password"}) + invented := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": "AAAAAA-BBBBBB-CCCCCC-DDDDDD", "password": "a-good-long-password"}) + + if spent.Code != invented.Code || spent.Body.String() != invented.Body.String() { + t.Fatalf("a spent code is distinguishable from an invented one:\n%d %s\n%d %s", + spent.Code, spent.Body.String(), invented.Code, invented.Body.String()) + } +} + +func TestStaffCannotInviteAndAManagerCannotMintAnOwner(t *testing.T) { + s, fs := newServer(t) + seedMember(fs, acmeStaffID, "sam@acme.com", "Sam", "staff") + seedUser(fs) + + staff := login(t, s, "sam@acme.com", "correct horse battery") + if rec := do(t, s, "POST", "/api/team/invitations", staff.Token, + map[string]any{"email": "x@acme.com"}); rec.Code != http.StatusForbidden { + t.Errorf("staff should not be able to invite, got %d", rec.Code) + } + + // A manager promoting somebody past themselves is an escalation, and it is + // the shape of this endpoint that would matter if a manager account were + // ever taken over. + mgr := login(t, s, "manager@acme.com", "correct horse battery") + if rec := do(t, s, "POST", "/api/team/invitations", mgr.Token, + map[string]any{"email": "boss@acme.com", "role": "owner"}); rec.Code != http.StatusForbidden { + t.Errorf("a manager minted an owner invitation, got %d", rec.Code) + } +} + +// 'admin' is a platform administrator, which is defined by having no company at +// all. An invitation always carries one, so the role could never work - what it +// could do is create the tenant-scoped row with role='admin' that adminOnly +// exists to reject. +func TestAnInvitationCannotMintAPlatformAdmin(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + + rec := do(t, s, "POST", "/api/team/invitations", sess.Token, + map[string]any{"email": "root@acme.com", "role": "admin"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("admin should not be an invitable role, got %d: %s", + rec.Code, rec.Body.String()) + } +} + +func TestAnInvitationIsScopedToTheInvitersCompany(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + fs.addUser("other@beta.com", "correct horse battery", UserRecord{ + ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd", + FullName: "Bo", Role: "manager", Active: true, + }) + acme := login(t, s, "manager@acme.com", "correct horse battery") + beta := login(t, s, "other@beta.com", "correct horse battery") + + inv := invite(t, s, acme.Token, map[string]any{"email": "new@acme.com"}) + + // Beta cannot see it... + rec := do(t, s, "GET", "/api/team/invitations", beta.Token, nil) + if strings.Contains(rec.Body.String(), "new@acme.com") { + t.Fatalf("another tenant can see Acme's invitations: %s", rec.Body.String()) + } + // ...nor withdraw it. + if rec := do(t, s, "DELETE", "/api/team/invitations/"+inv.ID, beta.Token, nil); rec.Code == http.StatusNoContent { + t.Fatal("another tenant withdrew Acme's invitation") + } +} + +// The preview is unauthenticated by necessity - the holder has no account yet - +// so what it discloses is the whole question. +func TestThePreviewShowsWhatToJoinAndNothingElse(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + inv := invite(t, s, sess.Token, map[string]any{ + "email": "nikhil@acme.com", "full_name": "Nikhil", "role": "manager"}) + + rec := do(t, s, "GET", "/api/auth/invitation?code="+inv.Code, "", nil) + if rec.Code != http.StatusOK { + t.Fatalf("preview: %d %s", rec.Code, rec.Body.String()) + } + var prev InvitationPreview + if err := json.Unmarshal(rec.Body.Bytes(), &prev); err != nil { + t.Fatal(err) + } + if prev.Role != "manager" || prev.Email != "nikhil@acme.com" { + t.Errorf("preview should say what is being joined: %+v", prev) + } + // It must not become a way to read a company's staff list or anything else + // about it beyond the one line the code already asserts. + if strings.Contains(rec.Body.String(), "manager@acme.com") { + t.Error("the preview disclosed the inviter's address") + } + + if rec := do(t, s, "GET", "/api/auth/invitation?code=NOPE", "", nil); rec.Code != http.StatusNotFound { + t.Errorf("an invented code should 404, got %d", rec.Code) + } +} + +func TestRegistrationEnforcesThePasswordFloor(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + sess := login(t, s, "manager@acme.com", "correct horse battery") + inv := invite(t, s, sess.Token, map[string]any{"email": "short@acme.com"}) + + rec := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "short"}) + if rec.Code != http.StatusBadRequest { + t.Fatalf("a short password was accepted, got %d", rec.Code) + } + // And the invitation is NOT spent by a rejected attempt - otherwise one + // mistyped password would cost the person their invitation. + ok := do(t, s, "POST", "/api/auth/register", "", map[string]any{ + "code": inv.Code, "password": "a-good-long-password"}) + if ok.Code != http.StatusCreated { + t.Fatalf("a failed attempt burned the invitation: %d %s", ok.Code, ok.Body.String()) + } +} + +// ------------------------------------------------------------------- team -- + +func TestDeactivatingSomebodySignsThemOutNow(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + seedMember(fs, acmeStaffID, "leaver@acme.com", "Lee", "staff") + mgr := login(t, s, "manager@acme.com", "correct horse battery") + leaver := login(t, s, "leaver@acme.com", "correct horse battery") + + if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusOK { + t.Fatalf("the leaver should be signed in to begin with, got %d", rec.Code) + } + + rec := do(t, s, "PATCH", "/api/team/"+acmeStaffID, mgr.Token, map[string]any{"active": false}) + if rec.Code != http.StatusOK { + t.Fatalf("deactivate: %d %s", rec.Code, rec.Body.String()) + } + // The whole point. An access token lives twelve hours, so without revoking + // the session, "remove their access" would remove it sometime tomorrow - + // which is not what anybody pressing that button believes they have done. + if rec := do(t, s, "GET", "/api/auth/me", leaver.Token, nil); rec.Code != http.StatusUnauthorized { + t.Fatalf("a deactivated account is still signed in, got %d", rec.Code) + } +} + +func TestTheLastOwnerCannotRemoveThemselves(t *testing.T) { + s, fs := newServer(t) + seedMember(fs, acmeOwnerID, "boss@acme.com", "Bea", "owner") + sess := login(t, s, "boss@acme.com", "correct horse battery") + + for _, body := range []map[string]any{{"active": false}, {"role": "staff"}} { + rec := do(t, s, "PATCH", "/api/team/"+acmeOwnerID, sess.Token, body) + if rec.Code != http.StatusConflict { + t.Fatalf("the only owner removed themselves with %v: %d %s", + body, rec.Code, rec.Body.String()) + } + } +} + +func TestTeamIsScopedToTheCallersCompany(t *testing.T) { + s, fs := newServer(t) + seedUser(fs) + fs.addUser("other@beta.com", "correct horse battery", UserRecord{ + ID: "u2", ClientID: "client-beta", ClientName: "Beta Ltd", + FullName: "Bo", Role: "manager", Active: true, + }) + seedMember(fs, acmeOtherID, "asha@acme.com", "Asha", "manager") + beta := login(t, s, "other@beta.com", "correct horse battery") + + rec := do(t, s, "GET", "/api/team", beta.Token, nil) + if strings.Contains(rec.Body.String(), "manager@acme.com") { + t.Fatalf("another tenant's staff are visible: %s", rec.Body.String()) + } + // And a uuid guessed from elsewhere changes nothing. + // A real, well-formed id belonging to the OTHER tenant. The 404 must come + // from the client scope in the UPDATE, not from the shape check above it. + if rec := do(t, s, "PATCH", "/api/team/"+acmeOtherID, beta.Token, + map[string]any{"role": "staff"}); rec.Code != http.StatusNotFound { + t.Errorf("cross-tenant team edit was not refused, got %d", rec.Code) + } +} diff --git a/server/internal/api/types.go b/server/internal/api/types.go index 05e3ad2..f58537d 100644 --- a/server/internal/api/types.go +++ b/server/internal/api/types.go @@ -283,6 +283,16 @@ type Image struct { Available bool `json:"available"` URL string `json:"url,omitempty"` ExpiresIn int `json:"expires_in,omitempty"` + // Auth says the URL is one of ours and needs this session's bearer token, + // rather than a presigned object-store link that carries its own signature. + // + // It exists because the two are genuinely different to fetch and a client + // cannot tell them apart by looking. A browser can load the signed + // one and CANNOT load this one, so the web app fetches it and hands over an + // object URL; a mobile image view can attach the header and load it + // directly. Guessing from whether the URL is absolute would work today and + // break the first time object storage lives on the same host. + Auth bool `json:"auth,omitempty"` // Reason is user-facing prose, present only when Available is false. Reason string `json:"reason,omitempty"` // Key is the object-store key, carried from the store to the handler that @@ -588,3 +598,100 @@ type CheckStep struct { Detail string `json:"detail"` Advice string `json:"advice,omitempty"` } + +// ==================================================== team and invitations == + +// NewInvitation is an invitation about to be written. Only the hash crosses +// this boundary; the plaintext code exists in the handler and in the one +// response that returns it, and nowhere else. +type NewInvitation struct { + ClientID string + Email string + FullName string + Role string + CodeHash []byte + InvitedBy string + ExpiresAt time.Time +} + +// Invitation is a pending invitation as a manager sees it. It carries no code: +// the plaintext is returned exactly once, by the request that created it, and +// is not recoverable afterwards. A code a support engineer can look up later is +// a code anyone with support access can redeem. +type Invitation struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"full_name,omitempty"` + Role string `json:"role"` + InvitedBy string `json:"invited_by,omitempty"` + ExpiresAt string `json:"expires_at"` + CreatedAt string `json:"created_at"` + // Code is present ONLY on the response that mints it. + Code string `json:"code,omitempty"` +} + +// InvitationPreview is what an unauthenticated client may learn from a code it +// already holds: which company, for which address, in what role. +// +// Enough to render "Join TeNext Retail as a manager" before asking somebody to +// choose a password, and no more. Unknown, expired, spent and revoked codes are +// all one answer, for the reason enrolment already records: the difference only +// helps somebody guessing, and the holder's next step is identical in all four +// cases. +type InvitationPreview struct { + Client string `json:"client_name"` + Email string `json:"email"` + FullName string `json:"full_name,omitempty"` + Role string `json:"role"` +} + +// Registration is a redeemed invitation turning into an account. The email and +// role come from the INVITATION, never from the request body: a code forwarded +// to somebody else must not become an account for them, and a staff invitation +// must not be redeemed into an owner. +type Registration struct { + Code string + FullName string + Password string + Device string +} + +// TeamMember is one person in a company, as the team screen lists them. +type TeamMember struct { + ID string `json:"id"` + Email string `json:"email"` + FullName string `json:"full_name"` + Role string `json:"role"` + Active bool `json:"active"` + LastLoginAt string `json:"last_login_at,omitempty"` + CreatedAt string `json:"created_at"` +} + +// TeamUpdate changes one member. Both fields are optional; a nil means "leave +// this alone", which is what lets one endpoint serve "make them a manager" and +// "they have left" without either silently doing the other. +type TeamUpdate struct { + Role *string `json:"role,omitempty"` + Active *bool `json:"active,omitempty"` +} + +// ==================================================== devices and sessions == + +// DeviceSession is one signed-in device, as its owner sees it. +// +// This list is the point of opaque tokens rather than JWTs. The whole argument +// for a session table was that "log that device out, now" has to actually work +// on a product that puts customer data on shop-floor PCs and staff phones that +// get lost, resold and shared - and until this existed there was no way to ask +// what was signed in, let alone stop it. +type DeviceSession struct { + ID string `json:"id"` + Device string `json:"device"` + CreatedAt string `json:"created_at"` + LastUsedAt string `json:"last_used_at,omitempty"` + ExpiresAt string `json:"expires_at"` + // Current marks the session making this request, so a client can label it + // and can warn before somebody signs themselves out of the device in their + // hand. + Current bool `json:"current"` +} diff --git a/server/internal/store/api_faces.go b/server/internal/store/api_faces.go new file mode 100644 index 0000000..e60d6bc --- /dev/null +++ b/server/internal/store/api_faces.go @@ -0,0 +1,187 @@ +package store + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/jackc/pgx/v5" +) + +// Face images held by this server, for a deployment with no object storage. +// +// The bucket stays primary wherever one is configured: a presigned PUT never +// passes the bytes through the API at all, which is what makes it the right +// route at estate scale. This is the fallback that stops "no S3 account" from +// meaning "no photograph of any customer, ever" - see migration 011 for why it +// is bounded and therefore safe to keep here. + +// DBKeyPrefix marks an image key that names a row in this database rather than +// an object in a bucket. +// +// One column, `visits.image_key`, names either. A prefix rather than a second +// nullable column because every read already has the key in hand and can tell +// which store to ask without a further lookup - and because a key that does not +// say where it lives is a key some future caller will hand to the wrong one. +const DBKeyPrefix = "db:" + +// ErrNoFace means there is no stored image under that key. Ordinary absence, +// not a fault: most deployments store no faces at all. +var ErrNoFace = errors.New("no such face image") + +// PutVisitFace stores one face crop and returns the key that names it. +// +// The client and site come from the AGENT'S credential, never from the request, +// so a shop PC cannot file an image under another tenant. There is no visitor +// id yet - the server has not matched the template at this point - so the row +// is claimed later, by RecordVisit, and swept if that never happens. +func (s *Store) PutVisitFace(ctx context.Context, clientID, siteID string, + jpeg []byte) (string, error) { + + var id string + err := s.pool.QueryRow(ctx, ` + INSERT INTO visit_faces (client_id, site_id, image, bytes) + VALUES ($1::uuid, $2::uuid, $3, $4) + RETURNING id::text`, clientID, siteID, jpeg, len(jpeg)).Scan(&id) + if err != nil { + return "", fmt.Errorf("store face: %w", err) + } + return DBKeyPrefix + id, nil +} + +// VisitFace reads one back, scoped to the tenant that is asking. +// +// The client id is in the WHERE clause and not merely checked afterwards: an +// image key travels in an API response, and a caller who kept one from a +// previous tenancy - or guessed one - must get nothing rather than a photograph +// of somebody else's customer. +func (s *Store) VisitFace(ctx context.Context, clientID, key string) ([]byte, error) { + id, ok := strings.CutPrefix(key, DBKeyPrefix) + if !ok || !looksLikeUUID(id) { + return nil, ErrNoFace + } + var img []byte + err := s.pool.QueryRow(ctx, ` + SELECT image FROM visit_faces + WHERE id = $1::uuid AND client_id = $2::uuid`, id, clientID).Scan(&img) + if errors.Is(err, pgx.ErrNoRows) { + return nil, ErrNoFace + } + if err != nil { + return nil, fmt.Errorf("read face: %w", err) + } + return img, nil +} + +// DeleteVisitFaces erases stored faces outright. +// +// Used by the erasure path, which must destroy the image rather than unlink it. +// The rule the bucket path already follows applies unchanged: a face image that +// survives an erasure request is the one outcome that endpoint must never +// produce, so a failure here has to reach the caller. +func (s *Store) DeleteVisitFaces(ctx context.Context, clientID string, keys []string) error { + ids := make([]string, 0, len(keys)) + for _, k := range keys { + if id, ok := strings.CutPrefix(k, DBKeyPrefix); ok && looksLikeUUID(id) { + ids = append(ids, id) + } + } + if len(ids) == 0 { + return nil + } + _, err := s.pool.Exec(ctx, ` + DELETE FROM visit_faces + WHERE client_id = $1::uuid AND id = ANY($2::uuid[])`, clientID, ids) + if err != nil { + return fmt.Errorf("delete faces: %w", err) + } + return nil +} + +// pruneVisitorFaces keeps ONE stored face per visitor: the newest. +// +// This is what bounds the table to the customer base rather than to footfall, +// and it is the whole reason face images may live in Postgres at all. It runs +// inside RecordVisit's transaction, right after the visit is linked to a +// person, so the superseded row and the key that named it disappear together. +// +// ONE statement, and that is not tidiness. The first version read the old keys +// with `UPDATE visits SET image_key = ” ... RETURNING image_key` - which +// returns the value AFTER the update, so every key came back as the empty +// string it had just been set to, the delete list was always empty, and the +// table grew with footfall exactly as if the prune did not exist. The visits +// looked right; only the row count gave it away. A CTE cannot have that bug: +// `doomed` reads the pre-image, and both the update and the delete are driven +// from it. +// +// The old key is blanked rather than marked deleted. `image_deleted_at` means +// an erasure was performed and is what an auditor reads; borrowing it to mean +// "we kept a better photo" would put ordinary housekeeping into the record of +// legal requests. +func pruneVisitorFaces(ctx context.Context, tx pgx.Tx, clientID, visitorID, keepVisitID string) error { + _, err := tx.Exec(ctx, ` + WITH doomed AS ( + SELECT v.id, v.image_key + FROM visits v + WHERE v.client_id = $1::uuid + AND v.visitor_id = $2::uuid + AND v.id <> $3::uuid + AND v.image_key LIKE 'db:%' + ), cleared AS ( + UPDATE visits SET image_key = '' + WHERE id IN (SELECT id FROM doomed) + ) + DELETE FROM visit_faces f + WHERE f.client_id = $1::uuid + -- Joined on the text form deliberately: the alternative is casting a + -- substring of a stored key to uuid, which throws on a malformed row + -- and would take an ordinary visit down with it. + AND 'db:' || f.id::text IN (SELECT image_key FROM doomed)`, + clientID, visitorID, keepVisitID) + if err != nil { + return fmt.Errorf("prune faces: %w", err) + } + return nil +} + +// SweepOrphanFaces removes images no visit ever claimed. +// +// An agent uploads a face before the server has decided who it is, so a row is +// briefly unreferenced by design. It stays that way for good if the visit that +// would have claimed it never arrives - a dropped queue, a corrupt entry - and +// that is one stored photograph of a real person that nothing points at and +// nothing would ever delete. Erasure could not reach it either: it is found +// through the visitor, and this row has none. +func (s *Store) SweepOrphanFaces(ctx context.Context, olderThan string) (int, error) { + tag, err := s.pool.Exec(ctx, ` + DELETE FROM visit_faces f + WHERE f.captured_at < now() - $1::interval + AND NOT EXISTS ( + SELECT 1 FROM visits v + WHERE v.image_key = 'db:' || f.id::text)`, olderThan) + if err != nil { + return 0, fmt.Errorf("sweep faces: %w", err) + } + return int(tag.RowsAffected()), nil +} + +func looksLikeUUID(s string) bool { + if len(s) != 36 { + return false + } + for i, c := range s { + switch i { + case 8, 13, 18, 23: + if c != '-' { + return false + } + default: + isHex := (c >= '0' && c <= '9') || (c >= 'a' && c <= 'f') || (c >= 'A' && c <= 'F') + if !isHex { + return false + } + } + } + return true +} diff --git a/server/internal/store/api_faces_live_test.go b/server/internal/store/api_faces_live_test.go new file mode 100644 index 0000000..d6169a5 --- /dev/null +++ b/server/internal/store/api_faces_live_test.go @@ -0,0 +1,261 @@ +package store + +import ( + "context" + "fmt" + "testing" + "time" + + "github.com/loyaly/behavision-server/internal/contract" + "github.com/loyaly/behavision-server/internal/ingest" +) + +// Face images held by this server, against a real database. +// +// The prune is the whole reason this is allowed to live in Postgres at all - +// migration 011 argues it explicitly against 009's "face images grow with every +// visitor who ever walks in" - so it is the one behaviour that must be proved +// against the real thing rather than a fake that would simply agree with me. + +func seedAgentSite(t *testing.T, st *Store, name string) ingest.Site { + t.Helper() + ctx := context.Background() + var site ingest.Site + if err := st.pool.QueryRow(ctx, ` + INSERT INTO clients (name, slug) VALUES ($1, $1) RETURNING id::text`, + name).Scan(&site.ClientID); err != nil { + t.Fatalf("seed client: %v", err) + } + if err := st.pool.QueryRow(ctx, ` + INSERT INTO sites (client_id, name, slug) VALUES ($1::uuid, $2, $3) + RETURNING id::text`, site.ClientID, name, name).Scan(&site.SiteID); err != nil { + t.Fatalf("seed site: %v", err) + } + if err := st.pool.QueryRow(ctx, ` + INSERT INTO agents (client_id, site_id, mqtt_username) + VALUES ($1::uuid, $2::uuid, $3) + RETURNING id::text`, site.ClientID, site.SiteID, name).Scan(&site.AgentID); err != nil { + t.Fatalf("seed agent: %v", err) + } + site.Slug = name + return site +} + +func embedding(seed float32) []float32 { + v := make([]float32, contract.EmbeddingDim) + for i := range v { + v[i] = seed + } + return v +} + +// The bound: one person seen many times leaves ONE stored image, not one per +// visit. Without this the table grows with footfall, which is precisely the +// property that keeps face images out of the database everywhere else. +func TestLiveOnlyOneFaceSurvivesPerVisitor(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + site := seedAgentSite(t, st, "faces-"+stamp()) + + var keys []string + for i := 0; i < 5; i++ { + key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, + []byte(fmt.Sprintf("jpeg-%d", i))) + if err != nil { + t.Fatalf("store face %d: %v", i, err) + } + keys = append(keys, key) + + // The SAME person every time: one embedding, so the matcher resolves + // them to one visitor. + ok, err := st.RecordVisit(ctx, site, &contract.Visit{ + EventID: fmt.Sprintf("%s-%d", site.Slug, i), + OccurredAt: time.Now().UTC().Add(time.Duration(i) * time.Second), + CameraID: "door", + IsNew: i == 0, + Quality: 0.8, + Similarity: 0.9, + Embedding: embedding(0.05), + ImageKey: key, + }) + if err != nil || !ok { + t.Fatalf("visit %d: ok=%v err=%v", i, ok, err) + } + } + + var stored int + if err := st.pool.QueryRow(ctx, + `SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`, + site.ClientID).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != 1 { + t.Fatalf("five visits by one person left %d stored faces - the table "+ + "grows with footfall, which is exactly what migration 011 promises "+ + "it does not", stored) + } + + // And it is the NEWEST that survived: every surface shows a customer's + // latest view, so keeping an older one would quietly show a stale face. + var surviving string + if err := st.pool.QueryRow(ctx, + `SELECT 'db:' || id::text FROM visit_faces WHERE client_id = $1::uuid`, + site.ClientID).Scan(&surviving); err != nil { + t.Fatal(err) + } + if surviving != keys[len(keys)-1] { + t.Errorf("kept %s, want the newest %s", surviving, keys[len(keys)-1]) + } + + // The superseded keys are blanked, not left dangling. A visit advertising + // an image that is not there renders as a broken picture on the one screen + // meant to show it. + var dangling int + if err := st.pool.QueryRow(ctx, ` + SELECT count(*) FROM visits v + WHERE v.client_id = $1::uuid AND v.image_key LIKE 'db:%' + AND NOT EXISTS (SELECT 1 FROM visit_faces f + WHERE 'db:' || f.id::text = v.image_key)`, + site.ClientID).Scan(&dangling); err != nil { + t.Fatal(err) + } + if dangling != 0 { + t.Errorf("%d visits point at a face that is gone", dangling) + } + + // image_deleted_at is the record of an ERASURE and is what an auditor + // reads. Ordinary housekeeping must not write into it. + var marked int + if err := st.pool.QueryRow(ctx, ` + SELECT count(*) FROM visits + WHERE client_id = $1::uuid AND image_deleted_at IS NOT NULL`, + site.ClientID).Scan(&marked); err != nil { + t.Fatal(err) + } + if marked != 0 { + t.Errorf("%d visits were marked as erased by a routine prune", marked) + } +} + +// Two different people keep one face each. The prune must be scoped to the +// person, not to the site - otherwise every new arrival would delete the +// previous customer's photo. +func TestLiveThePruneIsPerPersonNotPerSite(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + site := seedAgentSite(t, st, "faces2-"+stamp()) + + for i, seed := range []float32{0.05, -0.05} { + key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, + []byte(fmt.Sprintf("person-%d", i))) + if err != nil { + t.Fatal(err) + } + if ok, err := st.RecordVisit(ctx, site, &contract.Visit{ + EventID: fmt.Sprintf("%s-p%d", site.Slug, i), + OccurredAt: time.Now().UTC(), + CameraID: "door", + IsNew: true, + Quality: 0.8, + Embedding: embedding(seed), + ImageKey: key, + }); err != nil || !ok { + t.Fatalf("visit: ok=%v err=%v", ok, err) + } + } + + var stored int + if err := st.pool.QueryRow(ctx, + `SELECT count(*) FROM visit_faces WHERE client_id = $1::uuid`, + site.ClientID).Scan(&stored); err != nil { + t.Fatal(err) + } + if stored != 2 { + t.Fatalf("two people should keep one face each, got %d", stored) + } +} + +// An agent uploads a face BEFORE the server has decided who it is, so a row is +// briefly unreferenced by design - and permanently so if the visit that would +// have claimed it never arrives. That is a stored photograph of a real person +// that nothing points at, which erasure could never reach because it is found +// through the visitor and this row has none. +func TestLiveAnUnclaimedFaceIsSweptAway(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + site := seedAgentSite(t, st, "faces3-"+stamp()) + + key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("orphan")) + if err != nil { + t.Fatal(err) + } + // Age it past the sweep window rather than sleeping. + if _, err := st.pool.Exec(ctx, ` + UPDATE visit_faces SET captured_at = now() - interval '3 days' + WHERE 'db:' || id::text = $1`, key); err != nil { + t.Fatal(err) + } + + n, err := st.SweepOrphanFaces(ctx, "1 day") + if err != nil { + t.Fatalf("sweep: %v", err) + } + if n < 1 { + t.Fatal("the orphan was not swept") + } + if _, err := st.VisitFace(ctx, site.ClientID, key); err == nil { + t.Fatal("the orphan is still readable") + } +} + +// A face a visit DOES point at must survive the sweep, however old it is. A +// regular customer's photo is exactly the row that gets old. +func TestLiveTheSweepKeepsAClaimedFace(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + site := seedAgentSite(t, st, "faces4-"+stamp()) + + key, err := st.PutVisitFace(ctx, site.ClientID, site.SiteID, []byte("kept")) + if err != nil { + t.Fatal(err) + } + if ok, err := st.RecordVisit(ctx, site, &contract.Visit{ + EventID: site.Slug + "-keep", OccurredAt: time.Now().UTC(), + CameraID: "door", IsNew: true, Quality: 0.8, + Embedding: embedding(0.07), ImageKey: key, + }); err != nil || !ok { + t.Fatalf("visit: ok=%v err=%v", ok, err) + } + if _, err := st.pool.Exec(ctx, ` + UPDATE visit_faces SET captured_at = now() - interval '400 days' + WHERE 'db:' || id::text = $1`, key); err != nil { + t.Fatal(err) + } + + if _, err := st.SweepOrphanFaces(ctx, "1 day"); err != nil { + t.Fatal(err) + } + if _, err := st.VisitFace(ctx, site.ClientID, key); err != nil { + t.Fatalf("a claimed face was swept away: %v", err) + } +} + +// An image key travels in API responses. A caller who kept one, or guessed one, +// must get nothing rather than another company's customer. +func TestLiveAFaceIsNotReadableByAnotherTenant(t *testing.T) { + st := liveStore(t) + ctx := context.Background() + a := seedAgentSite(t, st, "facesa-"+stamp()) + b := seedAgentSite(t, st, "facesb-"+stamp()) + + key, err := st.PutVisitFace(ctx, a.ClientID, a.SiteID, []byte("private")) + if err != nil { + t.Fatal(err) + } + if _, err := st.VisitFace(ctx, b.ClientID, key); err == nil { + t.Fatal("another tenant read a stored face") + } + if _, err := st.VisitFace(ctx, a.ClientID, key); err != nil { + t.Fatalf("the owning tenant could not read its own face: %v", err) + } +} diff --git a/server/internal/store/api_team.go b/server/internal/store/api_team.go new file mode 100644 index 0000000..a055a7c --- /dev/null +++ b/server/internal/store/api_team.go @@ -0,0 +1,347 @@ +package store + +import ( + "context" + "errors" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/loyaly/behavision-server/internal/api" +) + +// Adding people to a company, and taking them out again. +// +// Registration here is by invitation only. `handlers_team.go` carries the +// product argument; what matters at this layer is that every statement is +// scoped by the CALLER'S client id, taken from their session, so a manager +// cannot invite somebody into, list, or remove a member of a company that is +// not theirs by guessing a uuid. + +// CreateInvitation writes a pending invitation for one company. +// +// The client id is not trusted from a caller anywhere above this, but it is +// still joined against `clients` here rather than inserted blind: a foreign-key +// violation surfaces as an opaque 500, and a row that names a company which has +// since been deleted is worse than a clean refusal. +func (s *Store) CreateInvitation(ctx context.Context, in api.NewInvitation) (api.Invitation, error) { + var out api.Invitation + err := s.pool.QueryRow(ctx, ` + INSERT INTO invitations (client_id, email, full_name, role, code_hash, + invited_by, expires_at) + SELECT c.id, $2, $3, $4, $5, $6::uuid, $7 + FROM clients c + WHERE c.id = $1::uuid + RETURNING id::text, email, full_name, role, + to_char(expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + in.ClientID, in.Email, in.FullName, in.Role, in.CodeHash, + nullUUID(in.InvitedBy), in.ExpiresAt, + ).Scan(&out.ID, &out.Email, &out.FullName, &out.Role, + &out.ExpiresAt, &out.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return api.Invitation{}, errors.New("no such company") + } + if err != nil { + return api.Invitation{}, fmt.Errorf("create invitation: %w", err) + } + return out, nil +} + +// PendingInvitations lists the invitations that have been sent and not yet +// taken up. Spent and revoked rows are history and are deliberately not here: +// the question this list answers is "who is still waiting to join". +func (s *Store) PendingInvitations(ctx context.Context, clientID string) ([]api.Invitation, error) { + rows, err := s.pool.Query(ctx, ` + SELECT i.id::text, i.email, i.full_name, i.role, + COALESCE(u.full_name, u.email, ''), + to_char(i.expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + to_char(i.created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + FROM invitations i + LEFT JOIN app_users u ON u.id = i.invited_by + WHERE i.client_id = $1::uuid + AND i.used_at IS NULL AND i.revoked_at IS NULL + AND i.expires_at > now() + ORDER BY i.created_at DESC`, clientID) + if err != nil { + return nil, fmt.Errorf("list invitations: %w", err) + } + defer rows.Close() + + var out []api.Invitation + for rows.Next() { + var v api.Invitation + if err := rows.Scan(&v.ID, &v.Email, &v.FullName, &v.Role, + &v.InvitedBy, &v.ExpiresAt, &v.CreatedAt); err != nil { + return nil, err + } + out = append(out, v) + } + return out, rows.Err() +} + +// RevokeInvitation withdraws one before it is used. +// +// Scoped by client in the UPDATE, and it refuses an already-spent invitation +// rather than silently doing nothing: "I revoked it" and "somebody had already +// joined with it" need opposite follow-up actions from whoever asked. +func (s *Store) RevokeInvitation(ctx context.Context, clientID, id string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE invitations SET revoked_at = now() + WHERE id = $2::uuid AND client_id = $1::uuid + AND used_at IS NULL AND revoked_at IS NULL`, clientID, id) + if err != nil { + return fmt.Errorf("revoke invitation: %w", err) + } + if tag.RowsAffected() == 0 { + return errors.New("no such pending invitation") + } + return nil +} + +// InvitationByCode is the unauthenticated preview: what a holder may learn +// about a code they already have. +// +// Every way of not being valid returns the same error, so this cannot be used +// to tell an expired code from an invented one. +func (s *Store) InvitationByCode(ctx context.Context, hash []byte) (api.InvitationPreview, error) { + var out api.InvitationPreview + err := s.pool.QueryRow(ctx, ` + SELECT c.name, i.email, i.full_name, i.role + FROM invitations i + JOIN clients c ON c.id = i.client_id + WHERE i.code_hash = $1 + AND i.used_at IS NULL AND i.revoked_at IS NULL + AND i.expires_at > now()`, hash, + ).Scan(&out.Client, &out.Email, &out.FullName, &out.Role) + if err != nil { + return api.InvitationPreview{}, errors.New("that invitation is not valid") + } + return out, nil +} + +// RedeemInvitation turns a code into an account, in ONE transaction. +// +// Two properties, and both were learned elsewhere in this system: +// +// - Single use is enforced BY the update. `used_at IS NULL` and the write are +// one statement, so two people racing on one invitation cannot both win. +// Check-then-update would be exactly that race, and the loser would get a +// second account rather than an error. +// - The account and the redemption commit together. A spent invitation with +// no user behind it is an invitation nobody can use and nobody can see is +// broken; a user with the invitation still open is a second account waiting +// to be created by anyone who was forwarded the code. +// +// The email and the role come from the ROW, never from the request. A code +// passed on to a colleague must not become an account for them, and a staff +// invitation must not be redeemed as an owner. +func (s *Store) RedeemInvitation(ctx context.Context, hash []byte, + fullName, passwordHash string) (api.UserRecord, error) { + + tx, err := s.pool.Begin(ctx) + if err != nil { + return api.UserRecord{}, err + } + defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed + + var clientID, email, role, invitedName string + err = tx.QueryRow(ctx, ` + UPDATE invitations SET used_at = now() + WHERE code_hash = $1 + AND used_at IS NULL AND revoked_at IS NULL AND expires_at > now() + RETURNING client_id::text, email, role, full_name`, hash, + ).Scan(&clientID, &email, &role, &invitedName) + if errors.Is(err, pgx.ErrNoRows) { + return api.UserRecord{}, errors.New("that invitation is not valid") + } + if err != nil { + return api.UserRecord{}, fmt.Errorf("redeem invitation: %w", err) + } + + if fullName == "" { + // The inviter may have typed a name; use it rather than leaving a + // blank row that every screen then renders as an email address. + fullName = invitedName + } + + var rec api.UserRecord + err = tx.QueryRow(ctx, ` + INSERT INTO app_users (client_id, email, password_hash, full_name, role) + VALUES ($1::uuid, $2, $3, $4, $5) + RETURNING id::text, email, full_name, role`, + clientID, email, passwordHash, fullName, role, + ).Scan(&rec.ID, &rec.Email, &rec.FullName, &rec.Role) + if err != nil { + return api.UserRecord{}, fmt.Errorf("create user: %w", err) + } + + var clientName string + if err := tx.QueryRow(ctx, `SELECT name FROM clients WHERE id = $1::uuid`, + clientID).Scan(&clientName); err != nil { + return api.UserRecord{}, err + } + + // Recorded against the new account, not the inviter: this is the moment a + // person gained access, and the row should name who did. + rec.ClientID, rec.ClientName, rec.Active, rec.Found = clientID, clientName, true, true + + if err := tx.Commit(ctx); err != nil { + return api.UserRecord{}, err + } + return rec, nil +} + +// Team lists the people in one company. +func (s *Store) Team(ctx context.Context, clientID string) ([]api.TeamMember, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id::text, email, full_name, role, active, + COALESCE(to_char(last_login_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + FROM app_users + WHERE client_id = $1::uuid + ORDER BY active DESC, full_name, email`, clientID) + if err != nil { + return nil, fmt.Errorf("list team: %w", err) + } + defer rows.Close() + + var out []api.TeamMember + for rows.Next() { + var m api.TeamMember + if err := rows.Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active, + &m.LastLoginAt, &m.CreatedAt); err != nil { + return nil, err + } + out = append(out, m) + } + return out, rows.Err() +} + +// UpdateTeamMember changes a role, or deactivates somebody who has left. +// +// Deactivating REVOKES their sessions in the same transaction. Leaving them +// live would mean "remove their access" removed it in twelve hours' time, +// whenever their access token happened to expire - which is not what anybody +// pressing that button believes they have just done, and is precisely the case +// an opaque-token session table exists to handle. +func (s *Store) UpdateTeamMember(ctx context.Context, clientID, userID string, + up api.TeamUpdate) (api.TeamMember, error) { + + tx, err := s.pool.Begin(ctx) + if err != nil { + return api.TeamMember{}, err + } + defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed + + var m api.TeamMember + err = tx.QueryRow(ctx, ` + UPDATE app_users + SET role = COALESCE($3, role), + active = COALESCE($4, active) + WHERE id = $2::uuid AND client_id = $1::uuid + RETURNING id::text, email, full_name, role, active, + COALESCE(to_char(last_login_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"')`, + clientID, userID, up.Role, up.Active, + ).Scan(&m.ID, &m.Email, &m.FullName, &m.Role, &m.Active, + &m.LastLoginAt, &m.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return api.TeamMember{}, errors.New("no such team member") + } + if err != nil { + return api.TeamMember{}, fmt.Errorf("update team member: %w", err) + } + + if up.Active != nil && !*up.Active { + if _, err := tx.Exec(ctx, ` + UPDATE sessions SET revoked_at = now() + WHERE user_id = $1::uuid AND revoked_at IS NULL`, userID); err != nil { + return api.TeamMember{}, fmt.Errorf("revoke sessions: %w", err) + } + } + if err := tx.Commit(ctx); err != nil { + return api.TeamMember{}, err + } + return m, nil +} + +// OwnerCount counts the active owners of a company. +// +// Used to refuse the change that locks a company out of its own account: the +// last owner may not demote or deactivate themselves. There is no support path +// back from that except a shell on the server, which is the thing this whole +// surface exists to stop needing. +func (s *Store) OwnerCount(ctx context.Context, clientID string) (int, error) { + var n int + err := s.pool.QueryRow(ctx, ` + SELECT count(*) FROM app_users + WHERE client_id = $1::uuid AND role = 'owner' AND active`, clientID).Scan(&n) + return n, err +} + +// ============================================================== sessions ==== + +// UserSessions lists one person's live sessions, newest first. +func (s *Store) UserSessions(ctx context.Context, userID string) ([]api.DeviceSession, error) { + rows, err := s.pool.Query(ctx, ` + SELECT id::text, device, + to_char(created_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), + COALESCE(to_char(last_used_at AT TIME ZONE 'UTC', + 'YYYY-MM-DD"T"HH24:MI:SS"Z"'), ''), + to_char(refresh_expires_at AT TIME ZONE 'UTC', 'YYYY-MM-DD"T"HH24:MI:SS"Z"') + FROM sessions + WHERE user_id = $1::uuid AND revoked_at IS NULL + AND refresh_expires_at > now() + ORDER BY COALESCE(last_used_at, created_at) DESC`, userID) + if err != nil { + return nil, fmt.Errorf("list sessions: %w", err) + } + defer rows.Close() + + var out []api.DeviceSession + for rows.Next() { + var d api.DeviceSession + if err := rows.Scan(&d.ID, &d.Device, &d.CreatedAt, + &d.LastUsedAt, &d.ExpiresAt); err != nil { + return nil, err + } + out = append(out, d) + } + return out, rows.Err() +} + +// RevokeUserSession signs one device out. +// +// Scoped by user_id in the UPDATE, so a session id - which is not a secret and +// travels in a list - cannot be used to sign somebody else out. +func (s *Store) RevokeUserSession(ctx context.Context, userID, sessionID string) error { + tag, err := s.pool.Exec(ctx, ` + UPDATE sessions SET revoked_at = now() + WHERE id = $2::uuid AND user_id = $1::uuid AND revoked_at IS NULL`, + userID, sessionID) + if err != nil { + return fmt.Errorf("revoke session: %w", err) + } + if tag.RowsAffected() == 0 { + return errors.New("no such session") + } + return nil +} + +// RevokeOtherSessions is the "sign out everywhere else" button. +// +// It keeps the caller's own session deliberately: somebody who has just lost a +// phone should not also be signed out of the device they are holding, which +// would leave them re-authenticating in the middle of an emergency. +func (s *Store) RevokeOtherSessions(ctx context.Context, userID, keepSessionID string) (int, error) { + tag, err := s.pool.Exec(ctx, ` + UPDATE sessions SET revoked_at = now() + WHERE user_id = $1::uuid AND id <> $2::uuid AND revoked_at IS NULL`, + userID, keepSessionID) + if err != nil { + return 0, fmt.Errorf("revoke sessions: %w", err) + } + return int(tag.RowsAffected()), nil +} diff --git a/server/internal/store/store.go b/server/internal/store/store.go index 83afdfc..b33b21d 100644 --- a/server/internal/store/store.go +++ b/server/internal/store/store.go @@ -150,6 +150,14 @@ func (s *Store) RecordVisit(ctx context.Context, site ingest.Site, visitorID, v.OccurredAt, site.ClientID); err != nil { return false, err } + // Now that we know who this was, drop any face this server was holding + // for them from an earlier visit. Only ever one survives per person, + // which is what bounds visit_faces to the customer base rather than to + // footfall - see migration 011. A bucket deployment writes no such keys + // and this does nothing. + if err := pruneVisitorFaces(ctx, tx, site.ClientID, visitorID, visitID); err != nil { + return false, err + } } if _, err := tx.Exec(ctx, diff --git a/server/internal/web/dist/assets/index-Bgt5SnW3.css b/server/internal/web/dist/assets/index-Bgt5SnW3.css new file mode 100644 index 0000000..460be27 --- /dev/null +++ b/server/internal/web/dist/assets/index-Bgt5SnW3.css @@ -0,0 +1 @@ +:root{--ground: #0E1317;--surface: #161D23;--surface-2: #1D262D;--line: #27333B;--line-soft: #1F2A31;--ink: #E7EEF3;--ink-2: #B4C2CC;--muted: #7C8B97;--accent: #45B0C7;--accent-dim:#123039;--ok: #4FB37B;--ok-dim: #12291F;--warn: #E0A33A;--warn-dim: #2C2313;--bad: #E0655A;--bad-dim: #2B1917;--radius: 10px;--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace}*{box-sizing:border-box;margin:0}html{color-scheme:dark}body{background:var(--ground);color:var(--ink);font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}code{font-family:var(--mono);font-size:.9em}h1{font-size:22px;font-weight:620;letter-spacing:-.015em}h2{font-size:16px;font-weight:600}h3{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.sub{color:var(--muted);font-size:13.5px}.num{font-variant-numeric:tabular-nums}.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.app{min-height:100vh;display:flex;flex-direction:column}.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:28px;padding:0 24px;height:60px;background:var(--surface);border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:11px}.brand strong{display:block;font-size:15px;font-weight:620;letter-spacing:-.01em}.brand .org{display:block;font-size:12px;color:var(--muted)}.mark{width:18px;height:18px;border-radius:50%;border:2.5px solid var(--accent);box-shadow:inset 0 0 0 3px var(--ground);flex:none}.mark.big{width:30px;height:30px;border-width:3px;margin-bottom:14px}.tabs{display:flex;gap:2px;margin-right:auto}.tabs button{background:none;border:0;border-radius:7px;padding:7px 13px;color:var(--ink-2);cursor:pointer;font-size:14px}.tabs button:hover{background:var(--surface-2);color:var(--ink)}.tabs button[aria-current=page]{background:var(--accent-dim);color:var(--accent);font-weight:550}.who{display:flex;align-items:center;gap:12px}.who .name{font-size:13.5px}.who .role{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.page{flex:1;padding:26px 24px 64px;max-width:1280px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:20px}.head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}.head h1{margin-right:auto}.head .sub{padding-bottom:2px}button.primary{background:var(--accent);color:#04161b;border:0;border-radius:7px;padding:9px 16px;font-weight:600;cursor:pointer}button.primary:disabled{opacity:.5;cursor:default}button.ghost{background:none;border:1px solid var(--line);border-radius:7px;padding:7px 13px;color:var(--ink-2);cursor:pointer}button.ghost:hover{border-color:var(--muted);color:var(--ink)}.segmented{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}.segmented button{background:none;border:0;padding:7px 14px;color:var(--ink-2);cursor:pointer;font-size:13.5px}.segmented button+button{border-left:1px solid var(--line)}.segmented button[aria-current]{background:var(--accent-dim);color:var(--accent)}.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius)}.grid{display:grid;gap:14px}.grid.sites{grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}.grid.stats{grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.card.site{overflow:hidden;padding:0;cursor:pointer;border-left:3px solid var(--line);transition:border-color .15s ease,transform .15s ease}.card.site.state-ok{border-left-color:var(--ok)}.card.site.state-warn{border-left-color:var(--warn)}.card.site.state-bad{border-left-color:var(--bad)}.card.site.state-idle{border-left-color:var(--muted)}.card.site:hover{transform:translateY(-1px)}.card.site:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media (prefers-reduced-motion: reduce){.card.site{transition:none}}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line-soft)}.metric{padding:11px 6px;text-align:center}.metric+.metric{border-left:1px solid var(--line-soft)}.metric b{display:block;font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}.metric b.ok{color:var(--ink)}.metric b.warn{color:var(--warn)}.metric b.bad{color:var(--bad)}.metric b.idle{color:var(--muted)}.metric span{display:block;margin-top:1px;font-size:11px;color:var(--muted)}.shopmark{width:38px;height:30px;fill:none;stroke:var(--line);stroke-width:2;stroke-linejoin:round}.pill{display:inline-block;padding:3px 9px;border-radius:20px;flex:none;font-size:11.5px;font-weight:550;letter-spacing:.01em;background:var(--surface-2);color:var(--ink-2)}.pill.ok{background:var(--ok-dim);color:var(--ok)}.pill.warn{background:var(--warn-dim);color:var(--warn)}.pill.bad{background:var(--bad-dim);color:var(--bad)}.pill.new{background:var(--accent-dim);color:var(--accent)}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:7px;vertical-align:1px}.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}.card.stat{padding:16px 18px;display:flex;flex-direction:column;gap:3px}.stat .value{font-size:30px;font-weight:620;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.15}.stat .label{font-size:13.5px;color:var(--ink-2)}.stat .note{font-size:12.5px;color:var(--muted);margin-top:3px}.banner{padding:12px 16px;border-radius:var(--radius);font-size:14px;display:flex;gap:16px;align-items:flex-start}.banner.warn{background:var(--warn-dim);border:1px solid #4A3A18;color:#f0d9a6}.banner.ok{background:var(--ok-dim);border:1px solid #1E4534;color:#c6e9d6}.banner>div{flex:1}.creds{display:flex;gap:26px;margin-top:10px;flex-wrap:wrap}.creds dt{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.creds dd{font-family:var(--mono);font-size:14px;-webkit-user-select:all;user-select:all}.arrivals{list-style:none;padding:0;display:grid;gap:8px}.card.arrival{display:flex;align-items:center;gap:14px;padding:11px 14px}.face{width:46px;height:46px;border-radius:50%;flex:none;overflow:hidden;object-fit:cover;background:var(--surface-2)}.face>img{width:100%;height:100%;object-fit:cover;display:block}.face.initials{display:grid;place-items:center;color:var(--muted);font-size:15px;font-weight:600;letter-spacing:.02em}.who-col{display:flex;flex-direction:column;gap:1px;flex:1;min-width:0}.who-col strong{font-weight:570}.attrs{font-size:12.5px;color:var(--muted);text-transform:capitalize}.toolbar{display:flex;gap:10px}.search{flex:1;max-width:380px;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 13px}.search::placeholder{color:var(--muted)}input::placeholder,textarea::placeholder{color:var(--muted);opacity:1}.tablewrap{overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius)}table.rows{border-collapse:collapse;width:100%;min-width:620px}table.rows th,table.rows td{text-align:left;padding:11px 16px;border-bottom:1px solid var(--line-soft)}table.rows tr:last-child td{border-bottom:0}table.rows th{font-size:11.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600;background:var(--surface-2)}table.rows tbody tr{cursor:pointer}table.rows tbody tr:hover{background:var(--surface-2)}table.rows td.num{font-variant-numeric:tabular-nums}.chart-card{padding:18px}.chart-head{margin-bottom:16px}.peak{font-size:12.5px;color:var(--muted);margin-bottom:8px}.peak b{color:var(--ink-2);font-weight:600;font-variant-numeric:tabular-nums}.chart{display:flex;align-items:flex-end;gap:3px;height:190px;overflow-x:auto;padding-bottom:4px}.col{flex:1;min-width:16px;display:flex;flex-direction:column;align-items:center;gap:6px;height:100%}.bars{flex:1;width:100%;display:flex;flex-direction:column;justify-content:flex-end}.bar{display:block;width:100%;border-radius:2px 2px 0 0}.bar.new{background:var(--accent)}.bar.returning{background:var(--accent-dim);border-radius:0}.col:hover .bar.returning{background:#1b4552}.tick{font-size:10.5px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}.legend{display:flex;gap:18px;align-items:center;margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);font-size:12.5px;color:var(--ink-2)}.legend span{display:flex;align-items:center;gap:7px}.swatch{width:10px;height:10px;border-radius:2px;display:inline-block}.swatch.new{background:var(--accent)}.swatch.returning{background:var(--accent-dim)}.pad{padding:24px 0}.overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#04080a9e;display:flex;justify-content:flex-end;z-index:50}.drawer{width:min(560px,100%);background:var(--surface);border-left:1px solid var(--line);height:100%;overflow-y:auto;display:flex;flex-direction:column}.drawer.narrow{width:min(440px,100%)}.drawer-head{position:sticky;top:0;z-index:1;display:flex;align-items:flex-start;gap:16px;padding:18px 22px;background:var(--surface);border-bottom:1px solid var(--line)}.drawer-head h2{margin-right:auto}.drawer-head>div{flex:1}.drawer-body{padding:20px 22px;display:flex;flex-direction:column;gap:14px}.drawer-body+.drawer-body{border-top:1px solid var(--line-soft)}fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:14px}fieldset:disabled{opacity:.6}label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--ink-2)}label input[type=text],label input[type=email],label input[type=password],label input:not([type]),.drawer input{background:var(--ground);border:1px solid var(--line);border-radius:7px;padding:9px 12px;color:var(--ink);font-size:14.5px}label .hint{font-size:12px;color:var(--muted);line-height:1.45}label.check{flex-direction:row;align-items:center;gap:9px}label.check input{width:auto}.timeline{list-style:none;padding:0;display:grid;gap:8px}.timeline li{display:flex;align-items:center;gap:12px;font-size:13.5px;padding:9px 12px;background:var(--ground);border-radius:7px}.timeline .when{font-variant-numeric:tabular-nums}.timeline .where{color:var(--muted);margin-right:auto}.state{display:flex;flex-direction:column;align-items:center;gap:8px;padding:64px 20px;text-align:center;color:var(--ink-2)}.state .sub{max-width:46ch}.boot{min-height:100vh;display:flex;align-items:center;justify-content:center;gap:10px;color:var(--muted)}.error{color:var(--bad);font-size:13.5px}.spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);animation:spin .7s linear infinite;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion: reduce){.spinner{animation:none}}.signin{min-height:100vh;display:grid;place-items:center;padding:24px}.signin .card{width:min(380px,100%);padding:30px;display:flex;flex-direction:column;gap:14px}.signin h1{font-size:20px}.signin .sub{margin-top:-8px;margin-bottom:6px}.signin .foot{font-size:12.5px;color:var(--muted);text-align:center;margin-top:4px;line-height:1.5}@media (max-width: 720px){.topbar{height:auto;flex-wrap:wrap;padding:12px 16px;gap:12px}.tabs{order:3;width:100%;overflow-x:auto}.page{padding:18px 16px 48px}.who .name{display:none}}.pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}select{background:var(--ground);border:1px solid var(--line);border-radius:7px;padding:9px 12px;color:var(--ink);font-size:14.5px}button.ghost.danger{border-color:#4a211d;color:var(--bad)}button.ghost.danger:hover{border-color:var(--bad)}.drawer.wizard{width:min(560px,100%)}.steps{list-style:none;display:flex;gap:4px;padding:14px 22px;margin:0;border-bottom:1px solid var(--line-soft);background:var(--surface);position:sticky;top:62px;z-index:1}.steps li{flex:1;display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--muted);min-width:0}.steps li .dot{width:20px;height:20px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:11px;background:var(--surface-2);color:var(--muted)}.steps li.now{color:var(--ink);font-weight:550}.steps li.now .dot{background:var(--accent);color:#04161b}.steps li.done .dot{background:var(--ok-dim);color:var(--ok)}.waiting{display:flex;gap:12px;align-items:center;padding:14px 16px;background:var(--ground);border:1px solid var(--line);border-radius:var(--radius)}.waiting>div{display:flex;flex-direction:column;gap:2px}.outcome{border:1px solid var(--line);border-left-width:3px;border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:12px}.outcome.ok{border-left-color:var(--ok);background:var(--ok-dim)}.outcome.warn{border-left-color:var(--warn);background:var(--warn-dim)}.outcome.bad{border-left-color:var(--bad);background:var(--bad-dim)}.outcome-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.outcome-head b{font-size:14.5px}.proof{width:100%;border-radius:6px;display:block;background:#05090b}.advice{margin:0;padding-left:18px;display:grid;gap:6px;font-size:13.5px;color:var(--ink-2);line-height:1.5}.verified{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:13px}.verified .mark{width:17px;height:17px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:10.5px}.verified.ok{color:var(--ok)}.verified.ok .mark{background:var(--ok-dim)}.verified.warn{color:var(--warn)}.verified.warn .mark{background:var(--warn-dim)}.verified.bad{color:var(--bad)}.verified.bad .mark{background:var(--bad-dim)}.verified.idle{color:var(--muted)}.verified.idle .mark{background:var(--surface-2)}.checklist{list-style:none;padding:0;margin:0;display:grid;gap:2px}.checklist li{display:flex;gap:12px;padding:13px 14px;background:var(--ground);border-radius:8px;border-left:3px solid var(--line)}.checklist li>div{display:flex;flex-direction:column;gap:3px;flex:1}.checklist li.pass{border-left-color:var(--ok)}.checklist li.warn{border-left-color:var(--warn)}.checklist li.fail{border-left-color:var(--bad)}.checklist li.unknown{border-left-color:var(--muted)}.checklist .mark{width:20px;height:20px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:11px;background:var(--surface-2);color:var(--muted)}.checklist li.pass .mark{background:var(--ok-dim);color:var(--ok)}.checklist li.warn .mark{background:var(--warn-dim);color:var(--warn)}.checklist li.fail .mark{background:var(--bad-dim);color:var(--bad)}.advice-line{font-size:13px;color:var(--warn);line-height:1.45}.checklist li.pass .advice-line{color:var(--muted)}.grid.cams{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}.card.cam{overflow:hidden;border-left:0;padding:0;cursor:pointer;transition:border-color .15s ease,transform .15s ease}.card.cam:hover{border-color:var(--muted);transform:translateY(-1px)}.card.cam:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media (prefers-reduced-motion: reduce){.card.cam{transition:none}}.shot{position:relative;aspect-ratio:16 / 10;background:#05090b;display:grid;place-items:center}.shot img{width:100%;height:100%;object-fit:cover;display:block}.noshot{display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--muted);font-size:12.5px;text-align:center;padding:0 28px;line-height:1.5}.lens{width:34px;height:34px;border-radius:50%;border:2px solid var(--line);position:relative}.lens:after{content:"";position:absolute;top:7px;right:7px;bottom:7px;left:7px;border-radius:50%;border:2px solid var(--line-soft)}.shot-over{position:absolute;inset:auto 0 0 0;display:flex;align-items:flex-end;justify-content:space-between;gap:10px;padding:26px 14px 12px;background:linear-gradient(transparent,#04080ad9)}.shot-name b{display:block;font-size:14.5px;font-weight:600;letter-spacing:-.01em}.shot-name span{display:block;font-size:11.5px;color:#9fb0ba;margin-top:1px}.status{display:flex;align-items:center;gap:6px;flex:none;font-size:11px;font-weight:550;letter-spacing:.01em;padding:4px 9px;border-radius:20px;white-space:nowrap;background:#0e1317b8;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:var(--ink-2)}.status i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.status.ok{color:#8fe0b4}.status.ok i{background:var(--ok)}.status.warn{color:#efc77c}.status.warn i{background:var(--warn)}.status.bad{color:#f0a79e}.status.bad i{background:var(--bad)}.status.idle i{background:var(--muted)}.shot-age{position:absolute;top:10px;right:12px;font-size:10.5px;color:#9fb0ba;background:#04080a99;padding:2px 7px;border-radius:20px;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.verdict{display:flex;align-items:center;gap:10px;padding:12px 14px;font-size:13px;border-top:1px solid var(--line-soft)}.verdict .words{flex:1;min-width:0}.verdict .go{color:var(--muted);font-size:14px}.verdict .mark{width:18px;height:18px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:10.5px}.verdict.ok{color:var(--ok)}.verdict.ok .mark{background:var(--ok-dim)}.verdict.warn{color:var(--warn)}.verdict.warn .mark{background:var(--warn-dim)}.verdict.bad{color:var(--bad)}.verdict.bad .mark{background:var(--bad-dim)}.verdict.idle{color:var(--muted)}.verdict.idle .mark{background:var(--surface-2)}.claim{margin-top:22px;padding-top:18px;border-top:1px solid var(--line-soft)}.claim h3{font-size:14px;font-weight:600;margin-bottom:6px}.claim .field{display:block;margin:12px 0}.claim .field span{display:block;font-size:12.5px;color:var(--muted);margin-bottom:5px}.claim .field input{width:100%;background:var(--ground);border:1px solid var(--line);border-radius:7px;padding:9px 12px}.claim .row{display:flex;gap:8px;margin-top:12px}.claim .code{font-family:var(--mono);font-size:19px;letter-spacing:.08em;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:14px 12px;text-align:center;margin:12px 0 10px;-webkit-user-select:all;user-select:all;word-break:break-all}.with-assistant{display:flex;flex:1;min-height:0}.with-assistant .page{flex:1;min-width:0}.ask-btn{background:var(--accent-dim);color:var(--accent);border:0;border-radius:7px;padding:7px 13px;cursor:pointer;font-size:13.5px;font-weight:550;display:flex;align-items:center;gap:7px}.ask-btn:hover,.ask-btn.on{background:var(--accent);color:#04161b}.assistant{width:360px;flex:none;border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;position:sticky;top:60px;height:calc(100vh - 60px)}.assistant-head{display:flex;align-items:flex-start;gap:12px;padding:15px 16px;border-bottom:1px solid var(--line-soft)}.assistant-head>div{flex:1;display:flex;flex-direction:column;gap:2px}.assistant-body{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}.assistant-ask{display:flex;gap:8px;padding:12px 14px;border-top:1px solid var(--line-soft)}.assistant-ask input{flex:1;min-width:0;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:9px 12px}.bubble{padding:11px 13px;border-radius:12px;font-size:14px;line-height:1.55;white-space:pre-wrap;max-width:100%}.bubble.user{background:var(--accent-dim);color:var(--ink);align-self:flex-end;border-bottom-right-radius:4px}.bubble.assistant{background:var(--ground);border:1px solid var(--line-soft);border-bottom-left-radius:4px}.bubble.failed{border-color:#4a211d;color:var(--bad)}.bubble.assistant-thinking{color:var(--muted);display:flex;align-items:center;gap:9px}.used{display:block;margin-top:8px;padding-top:7px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--muted)}.suggest{display:flex;flex-direction:column;align-items:flex-start;gap:8px}.chip{background:var(--ground);border:1px solid var(--line);border-radius:20px;padding:7px 13px;color:var(--ink-2);cursor:pointer;font-size:13px;text-align:left}.chip:hover{border-color:var(--accent);color:var(--accent)}@media (max-width: 900px){.with-assistant{flex-direction:column}.assistant{width:100%;height:60vh;position:static;border-left:0;border-top:1px solid var(--line)}}.card.cam .watch{position:absolute;left:10px;top:10px;z-index:2;display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;border:0;cursor:pointer;font:inherit;font-size:11.5px;font-weight:600;letter-spacing:.02em;color:#fff;background:#0c1014b8;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.card.cam .watch:hover{background:#0c1014e6}.card.cam .watch i{width:7px;height:7px;border-radius:50%;background:#e5484d;box-shadow:0 0 #e5484db3;animation:livepulse 2s infinite}@keyframes livepulse{70%{box-shadow:0 0 0 6px #e5484d00}to{box-shadow:0 0 #e5484d00}}@media (prefers-reduced-motion: reduce){.card.cam .watch i{animation:none}}.drawer.live{max-width:760px}.liveshot{position:relative;background:#000;border-radius:10px;overflow:hidden;aspect-ratio:16 / 9}.liveshot img{width:100%;height:100%;object-fit:contain;display:block}.livewait{position:absolute;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;font-size:13px;color:#ffffffc7;background:#00000059}.rows tr.inactive td{opacity:.55}.rows .role{text-transform:capitalize}.rows td.right{text-align:right}.pill.muted{margin-left:8px;font-size:11px;padding:1px 7px;border-radius:999px;background:var(--surface-2);color:var(--muted);vertical-align:middle}.pending{margin-top:26px}.pending h2{font-size:14px;font-weight:600;color:var(--muted);margin:0 0 10px}.invites{list-style:none;padding:0;display:grid;gap:8px}.card.invite{display:flex;align-items:center;justify-content:space-between;gap:14px;padding:11px 14px}.card.invite .sub{display:block}.creds code.big{font-size:16px;letter-spacing:.06em}.linkish{background:none;border:0;padding:0;font:inherit;color:var(--accent);cursor:pointer;text-decoration:underline;text-underline-offset:2px}.linkish:hover{opacity:.8}.codefield{font-family:ui-monospace,SFMono-Regular,Menlo,monospace;letter-spacing:.04em;text-transform:uppercase} diff --git a/server/internal/web/dist/assets/index-CtuyPF09.css b/server/internal/web/dist/assets/index-CtuyPF09.css deleted file mode 100644 index 865d5cd..0000000 --- a/server/internal/web/dist/assets/index-CtuyPF09.css +++ /dev/null @@ -1 +0,0 @@ -:root{--ground: #0E1317;--surface: #161D23;--surface-2: #1D262D;--line: #27333B;--line-soft: #1F2A31;--ink: #E7EEF3;--ink-2: #B4C2CC;--muted: #7C8B97;--accent: #45B0C7;--accent-dim:#123039;--ok: #4FB37B;--ok-dim: #12291F;--warn: #E0A33A;--warn-dim: #2C2313;--bad: #E0655A;--bad-dim: #2B1917;--radius: 10px;--mono: "SFMono-Regular", ui-monospace, Menlo, Consolas, monospace}*{box-sizing:border-box;margin:0}html{color-scheme:dark}body{background:var(--ground);color:var(--ink);font:15px/1.55 system-ui,-apple-system,Segoe UI,sans-serif;-webkit-font-smoothing:antialiased}button,input,select,textarea{font:inherit;color:inherit}:focus-visible{outline:2px solid var(--accent);outline-offset:2px}code{font-family:var(--mono);font-size:.9em}h1{font-size:22px;font-weight:620;letter-spacing:-.015em}h2{font-size:16px;font-weight:600}h3{font-size:13px;font-weight:600;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.sub{color:var(--muted);font-size:13.5px}.num{font-variant-numeric:tabular-nums}.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}.app{min-height:100vh;display:flex;flex-direction:column}.topbar{position:sticky;top:0;z-index:20;display:flex;align-items:center;gap:28px;padding:0 24px;height:60px;background:var(--surface);border-bottom:1px solid var(--line)}.brand{display:flex;align-items:center;gap:11px}.brand strong{display:block;font-size:15px;font-weight:620;letter-spacing:-.01em}.brand .org{display:block;font-size:12px;color:var(--muted)}.mark{width:18px;height:18px;border-radius:50%;border:2.5px solid var(--accent);box-shadow:inset 0 0 0 3px var(--ground);flex:none}.mark.big{width:30px;height:30px;border-width:3px;margin-bottom:14px}.tabs{display:flex;gap:2px;margin-right:auto}.tabs button{background:none;border:0;border-radius:7px;padding:7px 13px;color:var(--ink-2);cursor:pointer;font-size:14px}.tabs button:hover{background:var(--surface-2);color:var(--ink)}.tabs button[aria-current=page]{background:var(--accent-dim);color:var(--accent);font-weight:550}.who{display:flex;align-items:center;gap:12px}.who .name{font-size:13.5px}.who .role{font-size:11px;color:var(--muted);text-transform:uppercase;letter-spacing:.07em}.page{flex:1;padding:26px 24px 64px;max-width:1280px;width:100%;margin:0 auto;display:flex;flex-direction:column;gap:20px}.head{display:flex;align-items:flex-end;gap:16px;flex-wrap:wrap}.head h1{margin-right:auto}.head .sub{padding-bottom:2px}button.primary{background:var(--accent);color:#04161b;border:0;border-radius:7px;padding:9px 16px;font-weight:600;cursor:pointer}button.primary:disabled{opacity:.5;cursor:default}button.ghost{background:none;border:1px solid var(--line);border-radius:7px;padding:7px 13px;color:var(--ink-2);cursor:pointer}button.ghost:hover{border-color:var(--muted);color:var(--ink)}.segmented{display:flex;border:1px solid var(--line);border-radius:8px;overflow:hidden}.segmented button{background:none;border:0;padding:7px 14px;color:var(--ink-2);cursor:pointer;font-size:13.5px}.segmented button+button{border-left:1px solid var(--line)}.segmented button[aria-current]{background:var(--accent-dim);color:var(--accent)}.card{background:var(--surface);border:1px solid var(--line);border-radius:var(--radius)}.grid{display:grid;gap:14px}.grid.sites{grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px}.grid.stats{grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.card.site{overflow:hidden;padding:0;cursor:pointer;border-left:3px solid var(--line);transition:border-color .15s ease,transform .15s ease}.card.site.state-ok{border-left-color:var(--ok)}.card.site.state-warn{border-left-color:var(--warn)}.card.site.state-bad{border-left-color:var(--bad)}.card.site.state-idle{border-left-color:var(--muted)}.card.site:hover{transform:translateY(-1px)}.card.site:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media (prefers-reduced-motion: reduce){.card.site{transition:none}}.metrics{display:grid;grid-template-columns:repeat(3,1fr);border-top:1px solid var(--line-soft)}.metric{padding:11px 6px;text-align:center}.metric+.metric{border-left:1px solid var(--line-soft)}.metric b{display:block;font-size:15px;font-weight:600;font-variant-numeric:tabular-nums;letter-spacing:-.01em}.metric b.ok{color:var(--ink)}.metric b.warn{color:var(--warn)}.metric b.bad{color:var(--bad)}.metric b.idle{color:var(--muted)}.metric span{display:block;margin-top:1px;font-size:11px;color:var(--muted)}.shopmark{width:38px;height:30px;fill:none;stroke:var(--line);stroke-width:2;stroke-linejoin:round}.pill{display:inline-block;padding:3px 9px;border-radius:20px;flex:none;font-size:11.5px;font-weight:550;letter-spacing:.01em;background:var(--surface-2);color:var(--ink-2)}.pill.ok{background:var(--ok-dim);color:var(--ok)}.pill.warn{background:var(--warn-dim);color:var(--warn)}.pill.bad{background:var(--bad-dim);color:var(--bad)}.pill.new{background:var(--accent-dim);color:var(--accent)}.dot{display:inline-block;width:7px;height:7px;border-radius:50%;background:var(--muted);margin-right:7px;vertical-align:1px}.dot.ok{background:var(--ok)}.dot.warn{background:var(--warn)}.card.stat{padding:16px 18px;display:flex;flex-direction:column;gap:3px}.stat .value{font-size:30px;font-weight:620;letter-spacing:-.02em;font-variant-numeric:tabular-nums;line-height:1.15}.stat .label{font-size:13.5px;color:var(--ink-2)}.stat .note{font-size:12.5px;color:var(--muted);margin-top:3px}.banner{padding:12px 16px;border-radius:var(--radius);font-size:14px;display:flex;gap:16px;align-items:flex-start}.banner.warn{background:var(--warn-dim);border:1px solid #4A3A18;color:#f0d9a6}.banner.ok{background:var(--ok-dim);border:1px solid #1E4534;color:#c6e9d6}.banner>div{flex:1}.creds{display:flex;gap:26px;margin-top:10px;flex-wrap:wrap}.creds dt{font-size:11px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted)}.creds dd{font-family:var(--mono);font-size:14px;-webkit-user-select:all;user-select:all}.arrivals{list-style:none;padding:0;display:grid;gap:8px}.card.arrival{display:flex;align-items:center;gap:14px;padding:11px 14px}.face{width:46px;height:46px;border-radius:50%;flex:none;object-fit:cover;background:var(--surface-2)}.face.initials{display:grid;place-items:center;color:var(--muted);font-size:15px;font-weight:600;letter-spacing:.02em}.who-col{display:flex;flex-direction:column;gap:1px;flex:1;min-width:0}.who-col strong{font-weight:570}.attrs{font-size:12.5px;color:var(--muted);text-transform:capitalize}.toolbar{display:flex;gap:10px}.search{flex:1;max-width:380px;background:var(--surface);border:1px solid var(--line);border-radius:8px;padding:9px 13px}.search::placeholder{color:var(--muted)}input::placeholder,textarea::placeholder{color:var(--muted);opacity:1}.tablewrap{overflow-x:auto;background:var(--surface);border:1px solid var(--line);border-radius:var(--radius)}table.rows{border-collapse:collapse;width:100%;min-width:620px}table.rows th,table.rows td{text-align:left;padding:11px 16px;border-bottom:1px solid var(--line-soft)}table.rows tr:last-child td{border-bottom:0}table.rows th{font-size:11.5px;text-transform:uppercase;letter-spacing:.08em;color:var(--muted);font-weight:600;background:var(--surface-2)}table.rows tbody tr{cursor:pointer}table.rows tbody tr:hover{background:var(--surface-2)}table.rows td.num{font-variant-numeric:tabular-nums}.chart-card{padding:18px}.chart-head{margin-bottom:16px}.peak{font-size:12.5px;color:var(--muted);margin-bottom:8px}.peak b{color:var(--ink-2);font-weight:600;font-variant-numeric:tabular-nums}.chart{display:flex;align-items:flex-end;gap:3px;height:190px;overflow-x:auto;padding-bottom:4px}.col{flex:1;min-width:16px;display:flex;flex-direction:column;align-items:center;gap:6px;height:100%}.bars{flex:1;width:100%;display:flex;flex-direction:column;justify-content:flex-end}.bar{display:block;width:100%;border-radius:2px 2px 0 0}.bar.new{background:var(--accent)}.bar.returning{background:var(--accent-dim);border-radius:0}.col:hover .bar.returning{background:#1b4552}.tick{font-size:10.5px;color:var(--muted);white-space:nowrap;font-variant-numeric:tabular-nums}.legend{display:flex;gap:18px;align-items:center;margin-top:14px;padding-top:12px;border-top:1px solid var(--line-soft);font-size:12.5px;color:var(--ink-2)}.legend span{display:flex;align-items:center;gap:7px}.swatch{width:10px;height:10px;border-radius:2px;display:inline-block}.swatch.new{background:var(--accent)}.swatch.returning{background:var(--accent-dim)}.pad{padding:24px 0}.overlay{position:fixed;top:0;right:0;bottom:0;left:0;background:#04080a9e;display:flex;justify-content:flex-end;z-index:50}.drawer{width:min(560px,100%);background:var(--surface);border-left:1px solid var(--line);height:100%;overflow-y:auto;display:flex;flex-direction:column}.drawer.narrow{width:min(440px,100%)}.drawer-head{position:sticky;top:0;z-index:1;display:flex;align-items:flex-start;gap:16px;padding:18px 22px;background:var(--surface);border-bottom:1px solid var(--line)}.drawer-head h2{margin-right:auto}.drawer-head>div{flex:1}.drawer-body{padding:20px 22px;display:flex;flex-direction:column;gap:14px}.drawer-body+.drawer-body{border-top:1px solid var(--line-soft)}fieldset{border:0;padding:0;margin:0;display:flex;flex-direction:column;gap:14px}fieldset:disabled{opacity:.6}label{display:flex;flex-direction:column;gap:6px;font-size:13px;color:var(--ink-2)}label input[type=text],label input[type=email],label input[type=password],label input:not([type]),.drawer input{background:var(--ground);border:1px solid var(--line);border-radius:7px;padding:9px 12px;color:var(--ink);font-size:14.5px}label .hint{font-size:12px;color:var(--muted);line-height:1.45}label.check{flex-direction:row;align-items:center;gap:9px}label.check input{width:auto}.timeline{list-style:none;padding:0;display:grid;gap:8px}.timeline li{display:flex;align-items:center;gap:12px;font-size:13.5px;padding:9px 12px;background:var(--ground);border-radius:7px}.timeline .when{font-variant-numeric:tabular-nums}.timeline .where{color:var(--muted);margin-right:auto}.state{display:flex;flex-direction:column;align-items:center;gap:8px;padding:64px 20px;text-align:center;color:var(--ink-2)}.state .sub{max-width:46ch}.boot{min-height:100vh;display:flex;align-items:center;justify-content:center;gap:10px;color:var(--muted)}.error{color:var(--bad);font-size:13.5px}.spinner{width:14px;height:14px;border-radius:50%;border:2px solid var(--line);border-top-color:var(--accent);animation:spin .7s linear infinite;display:inline-block}@keyframes spin{to{transform:rotate(360deg)}}@media (prefers-reduced-motion: reduce){.spinner{animation:none}}.signin{min-height:100vh;display:grid;place-items:center;padding:24px}.signin .card{width:min(380px,100%);padding:30px;display:flex;flex-direction:column;gap:14px}.signin h1{font-size:20px}.signin .sub{margin-top:-8px;margin-bottom:6px}.signin .foot{font-size:12.5px;color:var(--muted);text-align:center;margin-top:4px;line-height:1.5}@media (max-width: 720px){.topbar{height:auto;flex-wrap:wrap;padding:12px 16px;gap:12px}.tabs{order:3;width:100%;overflow-x:auto}.page{padding:18px 16px 48px}.who .name{display:none}}.pair{display:grid;grid-template-columns:1fr 1fr;gap:14px}select{background:var(--ground);border:1px solid var(--line);border-radius:7px;padding:9px 12px;color:var(--ink);font-size:14.5px}button.ghost.danger{border-color:#4a211d;color:var(--bad)}button.ghost.danger:hover{border-color:var(--bad)}.drawer.wizard{width:min(560px,100%)}.steps{list-style:none;display:flex;gap:4px;padding:14px 22px;margin:0;border-bottom:1px solid var(--line-soft);background:var(--surface);position:sticky;top:62px;z-index:1}.steps li{flex:1;display:flex;align-items:center;gap:7px;font-size:12.5px;color:var(--muted);min-width:0}.steps li .dot{width:20px;height:20px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:11px;background:var(--surface-2);color:var(--muted)}.steps li.now{color:var(--ink);font-weight:550}.steps li.now .dot{background:var(--accent);color:#04161b}.steps li.done .dot{background:var(--ok-dim);color:var(--ok)}.waiting{display:flex;gap:12px;align-items:center;padding:14px 16px;background:var(--ground);border:1px solid var(--line);border-radius:var(--radius)}.waiting>div{display:flex;flex-direction:column;gap:2px}.outcome{border:1px solid var(--line);border-left-width:3px;border-radius:var(--radius);padding:14px 16px;display:flex;flex-direction:column;gap:12px}.outcome.ok{border-left-color:var(--ok);background:var(--ok-dim)}.outcome.warn{border-left-color:var(--warn);background:var(--warn-dim)}.outcome.bad{border-left-color:var(--bad);background:var(--bad-dim)}.outcome-head{display:flex;align-items:center;gap:10px;flex-wrap:wrap}.outcome-head b{font-size:14.5px}.proof{width:100%;border-radius:6px;display:block;background:#05090b}.advice{margin:0;padding-left:18px;display:grid;gap:6px;font-size:13.5px;color:var(--ink-2);line-height:1.5}.verified{display:flex;align-items:center;gap:8px;margin-top:10px;font-size:13px}.verified .mark{width:17px;height:17px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:10.5px}.verified.ok{color:var(--ok)}.verified.ok .mark{background:var(--ok-dim)}.verified.warn{color:var(--warn)}.verified.warn .mark{background:var(--warn-dim)}.verified.bad{color:var(--bad)}.verified.bad .mark{background:var(--bad-dim)}.verified.idle{color:var(--muted)}.verified.idle .mark{background:var(--surface-2)}.checklist{list-style:none;padding:0;margin:0;display:grid;gap:2px}.checklist li{display:flex;gap:12px;padding:13px 14px;background:var(--ground);border-radius:8px;border-left:3px solid var(--line)}.checklist li>div{display:flex;flex-direction:column;gap:3px;flex:1}.checklist li.pass{border-left-color:var(--ok)}.checklist li.warn{border-left-color:var(--warn)}.checklist li.fail{border-left-color:var(--bad)}.checklist li.unknown{border-left-color:var(--muted)}.checklist .mark{width:20px;height:20px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:11px;background:var(--surface-2);color:var(--muted)}.checklist li.pass .mark{background:var(--ok-dim);color:var(--ok)}.checklist li.warn .mark{background:var(--warn-dim);color:var(--warn)}.checklist li.fail .mark{background:var(--bad-dim);color:var(--bad)}.advice-line{font-size:13px;color:var(--warn);line-height:1.45}.checklist li.pass .advice-line{color:var(--muted)}.grid.cams{grid-template-columns:repeat(auto-fill,minmax(300px,1fr));gap:16px}.card.cam{overflow:hidden;border-left:0;padding:0;cursor:pointer;transition:border-color .15s ease,transform .15s ease}.card.cam:hover{border-color:var(--muted);transform:translateY(-1px)}.card.cam:focus-visible{outline:2px solid var(--accent);outline-offset:2px}@media (prefers-reduced-motion: reduce){.card.cam{transition:none}}.shot{position:relative;aspect-ratio:16 / 10;background:#05090b;display:grid;place-items:center}.shot img{width:100%;height:100%;object-fit:cover;display:block}.noshot{display:flex;flex-direction:column;align-items:center;gap:10px;color:var(--muted);font-size:12.5px;text-align:center;padding:0 28px;line-height:1.5}.lens{width:34px;height:34px;border-radius:50%;border:2px solid var(--line);position:relative}.lens:after{content:"";position:absolute;top:7px;right:7px;bottom:7px;left:7px;border-radius:50%;border:2px solid var(--line-soft)}.shot-over{position:absolute;inset:auto 0 0 0;display:flex;align-items:flex-end;justify-content:space-between;gap:10px;padding:26px 14px 12px;background:linear-gradient(transparent,#04080ad9)}.shot-name b{display:block;font-size:14.5px;font-weight:600;letter-spacing:-.01em}.shot-name span{display:block;font-size:11.5px;color:#9fb0ba;margin-top:1px}.status{display:flex;align-items:center;gap:6px;flex:none;font-size:11px;font-weight:550;letter-spacing:.01em;padding:4px 9px;border-radius:20px;white-space:nowrap;background:#0e1317b8;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);color:var(--ink-2)}.status i{width:6px;height:6px;border-radius:50%;background:var(--muted)}.status.ok{color:#8fe0b4}.status.ok i{background:var(--ok)}.status.warn{color:#efc77c}.status.warn i{background:var(--warn)}.status.bad{color:#f0a79e}.status.bad i{background:var(--bad)}.status.idle i{background:var(--muted)}.shot-age{position:absolute;top:10px;right:12px;font-size:10.5px;color:#9fb0ba;background:#04080a99;padding:2px 7px;border-radius:20px;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.verdict{display:flex;align-items:center;gap:10px;padding:12px 14px;font-size:13px;border-top:1px solid var(--line-soft)}.verdict .words{flex:1;min-width:0}.verdict .go{color:var(--muted);font-size:14px}.verdict .mark{width:18px;height:18px;border-radius:50%;flex:none;display:grid;place-items:center;font-size:10.5px}.verdict.ok{color:var(--ok)}.verdict.ok .mark{background:var(--ok-dim)}.verdict.warn{color:var(--warn)}.verdict.warn .mark{background:var(--warn-dim)}.verdict.bad{color:var(--bad)}.verdict.bad .mark{background:var(--bad-dim)}.verdict.idle{color:var(--muted)}.verdict.idle .mark{background:var(--surface-2)}.claim{margin-top:22px;padding-top:18px;border-top:1px solid var(--line-soft)}.claim h3{font-size:14px;font-weight:600;margin-bottom:6px}.claim .field{display:block;margin:12px 0}.claim .field span{display:block;font-size:12.5px;color:var(--muted);margin-bottom:5px}.claim .field input{width:100%;background:var(--ground);border:1px solid var(--line);border-radius:7px;padding:9px 12px}.claim .row{display:flex;gap:8px;margin-top:12px}.claim .code{font-family:var(--mono);font-size:19px;letter-spacing:.08em;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:14px 12px;text-align:center;margin:12px 0 10px;-webkit-user-select:all;user-select:all;word-break:break-all}.with-assistant{display:flex;flex:1;min-height:0}.with-assistant .page{flex:1;min-width:0}.ask-btn{background:var(--accent-dim);color:var(--accent);border:0;border-radius:7px;padding:7px 13px;cursor:pointer;font-size:13.5px;font-weight:550;display:flex;align-items:center;gap:7px}.ask-btn:hover,.ask-btn.on{background:var(--accent);color:#04161b}.assistant{width:360px;flex:none;border-left:1px solid var(--line);background:var(--surface);display:flex;flex-direction:column;position:sticky;top:60px;height:calc(100vh - 60px)}.assistant-head{display:flex;align-items:flex-start;gap:12px;padding:15px 16px;border-bottom:1px solid var(--line-soft)}.assistant-head>div{flex:1;display:flex;flex-direction:column;gap:2px}.assistant-body{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}.assistant-ask{display:flex;gap:8px;padding:12px 14px;border-top:1px solid var(--line-soft)}.assistant-ask input{flex:1;min-width:0;background:var(--ground);border:1px solid var(--line);border-radius:8px;padding:9px 12px}.bubble{padding:11px 13px;border-radius:12px;font-size:14px;line-height:1.55;white-space:pre-wrap;max-width:100%}.bubble.user{background:var(--accent-dim);color:var(--ink);align-self:flex-end;border-bottom-right-radius:4px}.bubble.assistant{background:var(--ground);border:1px solid var(--line-soft);border-bottom-left-radius:4px}.bubble.failed{border-color:#4a211d;color:var(--bad)}.bubble.assistant-thinking{color:var(--muted);display:flex;align-items:center;gap:9px}.used{display:block;margin-top:8px;padding-top:7px;border-top:1px solid var(--line-soft);font-size:11.5px;color:var(--muted)}.suggest{display:flex;flex-direction:column;align-items:flex-start;gap:8px}.chip{background:var(--ground);border:1px solid var(--line);border-radius:20px;padding:7px 13px;color:var(--ink-2);cursor:pointer;font-size:13px;text-align:left}.chip:hover{border-color:var(--accent);color:var(--accent)}@media (max-width: 900px){.with-assistant{flex-direction:column}.assistant{width:100%;height:60vh;position:static;border-left:0;border-top:1px solid var(--line)}}.card.cam .watch{position:absolute;left:10px;top:10px;z-index:2;display:inline-flex;align-items:center;gap:6px;padding:4px 9px;border-radius:999px;border:0;cursor:pointer;font:inherit;font-size:11.5px;font-weight:600;letter-spacing:.02em;color:#fff;background:#0c1014b8;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px)}.card.cam .watch:hover{background:#0c1014e6}.card.cam .watch i{width:7px;height:7px;border-radius:50%;background:#e5484d;box-shadow:0 0 #e5484db3;animation:livepulse 2s infinite}@keyframes livepulse{70%{box-shadow:0 0 0 6px #e5484d00}to{box-shadow:0 0 #e5484d00}}@media (prefers-reduced-motion: reduce){.card.cam .watch i{animation:none}}.drawer.live{max-width:760px}.liveshot{position:relative;background:#000;border-radius:10px;overflow:hidden;aspect-ratio:16 / 9}.liveshot img{width:100%;height:100%;object-fit:contain;display:block}.livewait{position:absolute;top:0;right:0;bottom:0;left:0;display:grid;place-items:center;font-size:13px;color:#ffffffc7;background:#00000059} diff --git a/server/internal/web/dist/assets/index-Dv7hKDIX.js b/server/internal/web/dist/assets/index-Dv7hKDIX.js new file mode 100644 index 0000000..61f46c3 --- /dev/null +++ b/server/internal/web/dist/assets/index-Dv7hKDIX.js @@ -0,0 +1,46 @@ +(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const s of l)if(s.type==="childList")for(const i of s.addedNodes)i.tagName==="LINK"&&i.rel==="modulepreload"&&r(i)}).observe(document,{childList:!0,subtree:!0});function n(l){const s={};return l.integrity&&(s.integrity=l.integrity),l.referrerPolicy&&(s.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?s.credentials="include":l.crossOrigin==="anonymous"?s.credentials="omit":s.credentials="same-origin",s}function r(l){if(l.ep)return;l.ep=!0;const s=n(l);fetch(l.href,s)}})();var da={exports:{}},pl={},fa={exports:{}},R={};/** + * @license React + * react.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var tr=Symbol.for("react.element"),_c=Symbol.for("react.portal"),Pc=Symbol.for("react.fragment"),Tc=Symbol.for("react.strict_mode"),zc=Symbol.for("react.profiler"),Lc=Symbol.for("react.provider"),Rc=Symbol.for("react.context"),Oc=Symbol.for("react.forward_ref"),Ic=Symbol.for("react.suspense"),Fc=Symbol.for("react.memo"),Dc=Symbol.for("react.lazy"),Zi=Symbol.iterator;function Mc(e){return e===null||typeof e!="object"?null:(e=Zi&&e[Zi]||e["@@iterator"],typeof e=="function"?e:null)}var pa={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},ha=Object.assign,ma={};function dn(e,t,n){this.props=e,this.context=t,this.refs=ma,this.updater=n||pa}dn.prototype.isReactComponent={};dn.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};dn.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function va(){}va.prototype=dn.prototype;function ei(e,t,n){this.props=e,this.context=t,this.refs=ma,this.updater=n||pa}var ti=ei.prototype=new va;ti.constructor=ei;ha(ti,dn.prototype);ti.isPureReactComponent=!0;var qi=Array.isArray,ya=Object.prototype.hasOwnProperty,ni={current:null},ga={key:!0,ref:!0,__self:!0,__source:!0};function wa(e,t,n){var r,l={},s=null,i=null;if(t!=null)for(r in t.ref!==void 0&&(i=t.ref),t.key!==void 0&&(s=""+t.key),t)ya.call(t,r)&&!ga.hasOwnProperty(r)&&(l[r]=t[r]);var a=arguments.length-2;if(a===1)l.children=n;else if(1>>1,q=C[G];if(0>>1;Gl(Rl,L))wtl(dr,Rl)?(C[G]=dr,C[wt]=L,G=wt):(C[G]=Rl,C[gt]=L,G=gt);else if(wtl(dr,L))C[G]=dr,C[wt]=L,G=wt;else break e}}return z}function l(C,z){var L=C.sortIndex-z.sortIndex;return L!==0?L:C.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var s=performance;e.unstable_now=function(){return s.now()}}else{var i=Date,a=i.now();e.unstable_now=function(){return i.now()-a}}var u=[],c=[],v=1,m=null,h=3,y=!1,x=!1,g=!1,T=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,d=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function f(C){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=C)r(c),z.sortIndex=z.expirationTime,t(u,z);else break;z=n(c)}}function w(C){if(g=!1,f(C),!x)if(n(u)!==null)x=!0,zl(S);else{var z=n(c);z!==null&&Ll(w,z.startTime-C)}}function S(C,z){x=!1,g&&(g=!1,p(P),P=-1),y=!0;var L=h;try{for(f(z),m=n(u);m!==null&&(!(m.expirationTime>z)||C&&!Te());){var G=m.callback;if(typeof G=="function"){m.callback=null,h=m.priorityLevel;var q=G(m.expirationTime<=z);z=e.unstable_now(),typeof q=="function"?m.callback=q:m===n(u)&&r(u),f(z)}else r(u);m=n(u)}if(m!==null)var cr=!0;else{var gt=n(c);gt!==null&&Ll(w,gt.startTime-z),cr=!1}return cr}finally{m=null,h=L,y=!1}}var E=!1,_=null,P=-1,K=5,O=-1;function Te(){return!(e.unstable_now()-OC||125G?(C.sortIndex=L,t(c,C),n(u)===null&&C===n(c)&&(g?(p(P),P=-1):g=!0,Ll(w,L-G))):(C.sortIndex=q,t(u,C),x||y||(x=!0,zl(S))),C},e.unstable_shouldYield=Te,e.unstable_wrapCallback=function(C){var z=h;return function(){var L=h;h=z;try{return C.apply(this,arguments)}finally{h=L}}}})(Na);Sa.exports=Na;var Yc=Sa.exports;/** + * @license React + * react-dom.production.min.js + * + * Copyright (c) Facebook, Inc. and its affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */var Xc=j,xe=Yc;function k(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),is=Object.prototype.hasOwnProperty,Jc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,eo={},to={};function Zc(e){return is.call(to,e)?!0:is.call(eo,e)?!1:Jc.test(e)?to[e]=!0:(eo[e]=!0,!1)}function qc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function bc(e,t,n,r){if(t===null||typeof t>"u"||qc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function de(e,t,n,r,l,s,i){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=s,this.removeEmptyString=i}var re={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){re[e]=new de(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];re[t]=new de(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){re[e]=new de(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){re[e]=new de(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){re[e]=new de(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){re[e]=new de(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){re[e]=new de(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){re[e]=new de(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){re[e]=new de(e,5,!1,e.toLowerCase(),null,!1,!1)});var li=/[\-:]([a-z])/g;function si(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(li,si);re[t]=new de(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(li,si);re[t]=new de(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(li,si);re[t]=new de(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){re[e]=new de(e,1,!1,e.toLowerCase(),null,!1,!1)});re.xlinkHref=new de("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){re[e]=new de(e,1,!1,e.toLowerCase(),null,!0,!0)});function ii(e,t,n,r){var l=re.hasOwnProperty(t)?re[t]:null;(l!==null?l.type!==0:r||!(2a||l[i]!==s[a]){var u=` +`+l[i].replace(" at new "," at ");return e.displayName&&u.includes("")&&(u=u.replace("",e.displayName)),u}while(1<=i&&0<=a);break}}}finally{Fl=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?Cn(e):""}function ed(e){switch(e.tag){case 5:return Cn(e.type);case 16:return Cn("Lazy");case 13:return Cn("Suspense");case 19:return Cn("SuspenseList");case 0:case 2:case 15:return e=Dl(e.type,!1),e;case 11:return e=Dl(e.type.render,!1),e;case 1:return e=Dl(e.type,!0),e;default:return""}}function cs(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case At:return"Fragment";case Ut:return"Portal";case os:return"Profiler";case oi:return"StrictMode";case as:return"Suspense";case us:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case _a:return(e.displayName||"Context")+".Consumer";case Ea:return(e._context.displayName||"Context")+".Provider";case ai:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case ui:return t=e.displayName||null,t!==null?t:cs(e.type)||"Memo";case be:t=e._payload,e=e._init;try{return cs(e(t))}catch{}}return null}function td(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return cs(t);case 8:return t===oi?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function pt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function Ta(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function nd(e){var t=Ta(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,s=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(i){r=""+i,s.call(this,i)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(i){r=""+i},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function hr(e){e._valueTracker||(e._valueTracker=nd(e))}function za(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=Ta(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Vr(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ds(e,t){var n=t.checked;return H({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function ro(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=pt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function La(e,t){t=t.checked,t!=null&&ii(e,"checked",t,!1)}function fs(e,t){La(e,t);var n=pt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?ps(e,t.type,n):t.hasOwnProperty("defaultValue")&&ps(e,t.type,pt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function lo(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function ps(e,t,n){(t!=="number"||Vr(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var En=Array.isArray;function Zt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=mr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Un(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var Tn={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},rd=["Webkit","ms","Moz","O"];Object.keys(Tn).forEach(function(e){rd.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),Tn[t]=Tn[e]})});function Fa(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||Tn.hasOwnProperty(e)&&Tn[e]?(""+t).trim():t+"px"}function Da(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Fa(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var ld=H({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function vs(e,t){if(t){if(ld[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(k(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(k(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(k(61))}if(t.style!=null&&typeof t.style!="object")throw Error(k(62))}}function ys(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var gs=null;function ci(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var ws=null,qt=null,bt=null;function oo(e){if(e=lr(e)){if(typeof ws!="function")throw Error(k(280));var t=e.stateNode;t&&(t=gl(t),ws(e.stateNode,e.type,t))}}function Ma(e){qt?bt?bt.push(e):bt=[e]:qt=e}function $a(){if(qt){var e=qt,t=bt;if(bt=qt=null,oo(e),t)for(e=0;e>>=0,e===0?32:31-(md(e)/vd|0)|0}var vr=64,yr=4194304;function _n(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Kr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,s=e.pingedLanes,i=n&268435455;if(i!==0){var a=i&~l;a!==0?r=_n(a):(s&=i,s!==0&&(r=_n(s)))}else i=n&~l,i!==0?r=_n(i):s!==0&&(r=_n(s));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,s=t&-t,l>=s||l===16&&(s&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function nr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ie(t),e[t]=n}function xd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Ln),yo=" ",go=!1;function lu(e,t){switch(e){case"keyup":return Yd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function su(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var Bt=!1;function Jd(e,t){switch(e){case"compositionend":return su(t);case"keypress":return t.which!==32?null:(go=!0,yo);case"textInput":return e=t.data,e===yo&&go?null:e;default:return null}}function Zd(e,t){if(Bt)return e==="compositionend"||!gi&&lu(e,t)?(e=nu(),Rr=mi=rt=null,Bt=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=jo(n)}}function uu(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?uu(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function cu(){for(var e=window,t=Vr();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Vr(e.document)}return t}function wi(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function of(e){var t=cu(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&uu(n.ownerDocument.documentElement,n)){if(r!==null&&wi(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,s=Math.min(r.start,l);r=r.end===void 0?s:Math.min(r.end,l),!e.extend&&s>r&&(l=r,r=s,s=l),l=So(n,s);var i=So(n,r);l&&i&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==i.node||e.focusOffset!==i.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),s>r?(e.addRange(t),e.extend(i.node,i.offset)):(t.setEnd(i.node,i.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Vt=null,Cs=null,On=null,Es=!1;function No(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Es||Vt==null||Vt!==Vr(r)||(r=Vt,"selectionStart"in r&&wi(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),On&&Qn(On,r)||(On=r,r=Xr(Cs,"onSelect"),0Qt||(e.current=Rs[Qt],Rs[Qt]=null,Qt--)}function M(e,t){Qt++,Rs[Qt]=e.current,e.current=t}var ht={},oe=vt(ht),he=vt(!1),Tt=ht;function ln(e,t){var n=e.type.contextTypes;if(!n)return ht;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},s;for(s in n)l[s]=t[s];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function me(e){return e=e.childContextTypes,e!=null}function Zr(){A(he),A(oe)}function Lo(e,t,n){if(oe.current!==ht)throw Error(k(168));M(oe,t),M(he,n)}function wu(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(k(108,td(e)||"Unknown",l));return H({},n,r)}function qr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||ht,Tt=oe.current,M(oe,e),M(he,he.current),!0}function Ro(e,t,n){var r=e.stateNode;if(!r)throw Error(k(169));n?(e=wu(e,t,Tt),r.__reactInternalMemoizedMergedChildContext=e,A(he),A(oe),M(oe,e)):A(he),M(he,n)}var We=null,wl=!1,Jl=!1;function xu(e){We===null?We=[e]:We.push(e)}function wf(e){wl=!0,xu(e)}function yt(){if(!Jl&&We!==null){Jl=!0;var e=0,t=F;try{var n=We;for(F=1;e>=i,l-=i,He=1<<32-Ie(t)+l|n<P?(K=_,_=null):K=_.sibling;var O=h(p,_,f[P],w);if(O===null){_===null&&(_=K);break}e&&_&&O.alternate===null&&t(p,_),d=s(O,d,P),E===null?S=O:E.sibling=O,E=O,_=K}if(P===f.length)return n(p,_),B&&xt(p,P),S;if(_===null){for(;PP?(K=_,_=null):K=_.sibling;var Te=h(p,_,O.value,w);if(Te===null){_===null&&(_=K);break}e&&_&&Te.alternate===null&&t(p,_),d=s(Te,d,P),E===null?S=Te:E.sibling=Te,E=Te,_=K}if(O.done)return n(p,_),B&&xt(p,P),S;if(_===null){for(;!O.done;P++,O=f.next())O=m(p,O.value,w),O!==null&&(d=s(O,d,P),E===null?S=O:E.sibling=O,E=O);return B&&xt(p,P),S}for(_=r(p,_);!O.done;P++,O=f.next())O=y(_,p,P,O.value,w),O!==null&&(e&&O.alternate!==null&&_.delete(O.key===null?P:O.key),d=s(O,d,P),E===null?S=O:E.sibling=O,E=O);return e&&_.forEach(function(hn){return t(p,hn)}),B&&xt(p,P),S}function T(p,d,f,w){if(typeof f=="object"&&f!==null&&f.type===At&&f.key===null&&(f=f.props.children),typeof f=="object"&&f!==null){switch(f.$$typeof){case pr:e:{for(var S=f.key,E=d;E!==null;){if(E.key===S){if(S=f.type,S===At){if(E.tag===7){n(p,E.sibling),d=l(E,f.props.children),d.return=p,p=d;break e}}else if(E.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===be&&Fo(S)===E.type){n(p,E.sibling),d=l(E,f.props),d.ref=kn(p,E,f),d.return=p,p=d;break e}n(p,E);break}else t(p,E);E=E.sibling}f.type===At?(d=_t(f.props.children,p.mode,w,f.key),d.return=p,p=d):(w=Ar(f.type,f.key,f.props,null,p.mode,w),w.ref=kn(p,d,f),w.return=p,p=w)}return i(p);case Ut:e:{for(E=f.key;d!==null;){if(d.key===E)if(d.tag===4&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){n(p,d.sibling),d=l(d,f.children||[]),d.return=p,p=d;break e}else{n(p,d);break}else t(p,d);d=d.sibling}d=ls(f,p.mode,w),d.return=p,p=d}return i(p);case be:return E=f._init,T(p,d,E(f._payload),w)}if(En(f))return x(p,d,f,w);if(vn(f))return g(p,d,f,w);Nr(p,f)}return typeof f=="string"&&f!==""||typeof f=="number"?(f=""+f,d!==null&&d.tag===6?(n(p,d.sibling),d=l(d,f),d.return=p,p=d):(n(p,d),d=rs(f,p.mode,w),d.return=p,p=d),i(p)):n(p,d)}return T}var on=Nu(!0),Cu=Nu(!1),tl=vt(null),nl=null,Yt=null,Si=null;function Ni(){Si=Yt=nl=null}function Ci(e){var t=tl.current;A(tl),e._currentValue=t}function Fs(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function tn(e,t){nl=e,Si=Yt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pe=!0),e.firstContext=null)}function _e(e){var t=e._currentValue;if(Si!==e)if(e={context:e,memoizedValue:t,next:null},Yt===null){if(nl===null)throw Error(k(308));Yt=e,nl.dependencies={lanes:0,firstContext:e}}else Yt=Yt.next=e;return t}var Nt=null;function Ei(e){Nt===null?Nt=[e]:Nt.push(e)}function Eu(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Ei(t)):(n.next=l.next,l.next=n),t.interleaved=n,Xe(e,r)}function Xe(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var et=!1;function _i(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function _u(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ke(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function ut(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,I&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Xe(e,n)}return l=r.interleaved,l===null?(t.next=t,Ei(r)):(t.next=l.next,l.next=t),r.interleaved=t,Xe(e,n)}function Ir(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,fi(e,n)}}function Do(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,s=null;if(n=n.firstBaseUpdate,n!==null){do{var i={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};s===null?l=s=i:s=s.next=i,n=n.next}while(n!==null);s===null?l=s=t:s=s.next=t}else l=s=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:s,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function rl(e,t,n,r){var l=e.updateQueue;et=!1;var s=l.firstBaseUpdate,i=l.lastBaseUpdate,a=l.shared.pending;if(a!==null){l.shared.pending=null;var u=a,c=u.next;u.next=null,i===null?s=c:i.next=c,i=u;var v=e.alternate;v!==null&&(v=v.updateQueue,a=v.lastBaseUpdate,a!==i&&(a===null?v.firstBaseUpdate=c:a.next=c,v.lastBaseUpdate=u))}if(s!==null){var m=l.baseState;i=0,v=c=u=null,a=s;do{var h=a.lane,y=a.eventTime;if((r&h)===h){v!==null&&(v=v.next={eventTime:y,lane:0,tag:a.tag,payload:a.payload,callback:a.callback,next:null});e:{var x=e,g=a;switch(h=t,y=n,g.tag){case 1:if(x=g.payload,typeof x=="function"){m=x.call(y,m,h);break e}m=x;break e;case 3:x.flags=x.flags&-65537|128;case 0:if(x=g.payload,h=typeof x=="function"?x.call(y,m,h):x,h==null)break e;m=H({},m,h);break e;case 2:et=!0}}a.callback!==null&&a.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[a]:h.push(a))}else y={eventTime:y,lane:h,tag:a.tag,payload:a.payload,callback:a.callback,next:null},v===null?(c=v=y,u=m):v=v.next=y,i|=h;if(a=a.next,a===null){if(a=l.shared.pending,a===null)break;h=a,a=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(v===null&&(u=m),l.baseState=u,l.firstBaseUpdate=c,l.lastBaseUpdate=v,t=l.shared.interleaved,t!==null){l=t;do i|=l.lane,l=l.next;while(l!==t)}else s===null&&(l.shared.lanes=0);Rt|=i,e.lanes=i,e.memoizedState=m}}function Mo(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=ql.transition;ql.transition={};try{e(!1),t()}finally{F=n,ql.transition=r}}function Hu(){return Pe().memoizedState}function Sf(e,t,n){var r=dt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Qu(e))Ku(t,n);else if(n=Eu(e,t,n,r),n!==null){var l=ue();Fe(n,e,r,l),Gu(n,t,r)}}function Nf(e,t,n){var r=dt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Qu(e))Ku(t,l);else{var s=e.alternate;if(e.lanes===0&&(s===null||s.lanes===0)&&(s=t.lastRenderedReducer,s!==null))try{var i=t.lastRenderedState,a=s(i,n);if(l.hasEagerState=!0,l.eagerState=a,De(a,i)){var u=t.interleaved;u===null?(l.next=l,Ei(t)):(l.next=u.next,u.next=l),t.interleaved=l;return}}catch{}finally{}n=Eu(e,t,l,r),n!==null&&(l=ue(),Fe(n,e,r,l),Gu(n,t,r))}}function Qu(e){var t=e.alternate;return e===W||t!==null&&t===W}function Ku(e,t){In=sl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Gu(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,fi(e,n)}}var il={readContext:_e,useCallback:le,useContext:le,useEffect:le,useImperativeHandle:le,useInsertionEffect:le,useLayoutEffect:le,useMemo:le,useReducer:le,useRef:le,useState:le,useDebugValue:le,useDeferredValue:le,useTransition:le,useMutableSource:le,useSyncExternalStore:le,useId:le,unstable_isNewReconciler:!1},Cf={readContext:_e,useCallback:function(e,t){return $e().memoizedState=[e,t===void 0?null:t],e},useContext:_e,useEffect:Uo,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Dr(4194308,4,Uu.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Dr(4194308,4,e,t)},useInsertionEffect:function(e,t){return Dr(4,2,e,t)},useMemo:function(e,t){var n=$e();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=$e();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Sf.bind(null,W,e),[r.memoizedState,e]},useRef:function(e){var t=$e();return e={current:e},t.memoizedState=e},useState:$o,useDebugValue:Fi,useDeferredValue:function(e){return $e().memoizedState=e},useTransition:function(){var e=$o(!1),t=e[0];return e=jf.bind(null,e[1]),$e().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=W,l=$e();if(B){if(n===void 0)throw Error(k(407));n=n()}else{if(n=t(),ee===null)throw Error(k(349));Lt&30||Lu(r,t,n)}l.memoizedState=n;var s={value:n,getSnapshot:t};return l.queue=s,Uo(Ou.bind(null,r,s,e),[e]),r.flags|=2048,bn(9,Ru.bind(null,r,s,n,t),void 0,null),n},useId:function(){var e=$e(),t=ee.identifierPrefix;if(B){var n=Qe,r=He;n=(r&~(1<<32-Ie(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Zn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=i.createElement(n,{is:r.is}):(e=i.createElement(n),n==="select"&&(i=e,r.multiple?i.multiple=!0:r.size&&(i.size=r.size))):e=i.createElementNS(e,n),e[Ue]=t,e[Yn]=r,rc(e,t,!1,!1),t.stateNode=e;e:{switch(i=ys(n,r),n){case"dialog":U("cancel",e),U("close",e),l=r;break;case"iframe":case"object":case"embed":U("load",e),l=r;break;case"video":case"audio":for(l=0;lcn&&(t.flags|=128,r=!0,jn(s,!1),t.lanes=4194304)}else{if(!r)if(e=ll(i),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),jn(s,!0),s.tail===null&&s.tailMode==="hidden"&&!i.alternate&&!B)return se(t),null}else 2*Y()-s.renderingStartTime>cn&&n!==1073741824&&(t.flags|=128,r=!0,jn(s,!1),t.lanes=4194304);s.isBackwards?(i.sibling=t.child,t.child=i):(n=s.last,n!==null?n.sibling=i:t.child=i,s.last=i)}return s.tail!==null?(t=s.tail,s.rendering=t,s.tail=t.sibling,s.renderingStartTime=Y(),t.sibling=null,n=V.current,M(V,r?n&1|2:n&1),t):(se(t),null);case 22:case 23:return Bi(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ye&1073741824&&(se(t),t.subtreeFlags&6&&(t.flags|=8192)):se(t),null;case 24:return null;case 25:return null}throw Error(k(156,t.tag))}function Of(e,t){switch(ki(t),t.tag){case 1:return me(t.type)&&Zr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return an(),A(he),A(oe),zi(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Ti(t),null;case 13:if(A(V),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(k(340));sn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return A(V),null;case 4:return an(),null;case 10:return Ci(t.type._context),null;case 22:case 23:return Bi(),null;case 24:return null;default:return null}}var Er=!1,ie=!1,If=typeof WeakSet=="function"?WeakSet:Set,N=null;function Xt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){Q(e,t,r)}else n.current=null}function Hs(e,t,n){try{n()}catch(r){Q(e,t,r)}}var Jo=!1;function Ff(e,t){if(_s=Gr,e=cu(),wi(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,s=r.focusNode;r=r.focusOffset;try{n.nodeType,s.nodeType}catch{n=null;break e}var i=0,a=-1,u=-1,c=0,v=0,m=e,h=null;t:for(;;){for(var y;m!==n||l!==0&&m.nodeType!==3||(a=i+l),m!==s||r!==0&&m.nodeType!==3||(u=i+r),m.nodeType===3&&(i+=m.nodeValue.length),(y=m.firstChild)!==null;)h=m,m=y;for(;;){if(m===e)break t;if(h===n&&++c===l&&(a=i),h===s&&++v===r&&(u=i),(y=m.nextSibling)!==null)break;m=h,h=m.parentNode}m=y}n=a===-1||u===-1?null:{start:a,end:u}}else n=null}n=n||{start:0,end:0}}else n=null;for(Ps={focusedElem:e,selectionRange:n},Gr=!1,N=t;N!==null;)if(t=N,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,N=e;else for(;N!==null;){t=N;try{var x=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(x!==null){var g=x.memoizedProps,T=x.memoizedState,p=t.stateNode,d=p.getSnapshotBeforeUpdate(t.elementType===t.type?g:Le(t.type,g),T);p.__reactInternalSnapshotBeforeUpdate=d}break;case 3:var f=t.stateNode.containerInfo;f.nodeType===1?f.textContent="":f.nodeType===9&&f.documentElement&&f.removeChild(f.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(k(163))}}catch(w){Q(t,t.return,w)}if(e=t.sibling,e!==null){e.return=t.return,N=e;break}N=t.return}return x=Jo,Jo=!1,x}function Fn(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var s=l.destroy;l.destroy=void 0,s!==void 0&&Hs(t,n,s)}l=l.next}while(l!==r)}}function jl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Qs(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function ic(e){var t=e.alternate;t!==null&&(e.alternate=null,ic(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ue],delete t[Yn],delete t[Ls],delete t[yf],delete t[gf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function oc(e){return e.tag===5||e.tag===3||e.tag===4}function Zo(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||oc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Ks(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Jr));else if(r!==4&&(e=e.child,e!==null))for(Ks(e,t,n),e=e.sibling;e!==null;)Ks(e,t,n),e=e.sibling}function Gs(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Gs(e,t,n),e=e.sibling;e!==null;)Gs(e,t,n),e=e.sibling}var te=null,Re=!1;function qe(e,t,n){for(n=n.child;n!==null;)ac(e,t,n),n=n.sibling}function ac(e,t,n){if(Ae&&typeof Ae.onCommitFiberUnmount=="function")try{Ae.onCommitFiberUnmount(hl,n)}catch{}switch(n.tag){case 5:ie||Xt(n,t);case 6:var r=te,l=Re;te=null,qe(e,t,n),te=r,Re=l,te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):te.removeChild(n.stateNode));break;case 18:te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?Xl(e.parentNode,n):e.nodeType===1&&Xl(e,n),Wn(e)):Xl(te,n.stateNode));break;case 4:r=te,l=Re,te=n.stateNode.containerInfo,Re=!0,qe(e,t,n),te=r,Re=l;break;case 0:case 11:case 14:case 15:if(!ie&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var s=l,i=s.destroy;s=s.tag,i!==void 0&&(s&2||s&4)&&Hs(n,t,i),l=l.next}while(l!==r)}qe(e,t,n);break;case 1:if(!ie&&(Xt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(a){Q(n,t,a)}qe(e,t,n);break;case 21:qe(e,t,n);break;case 22:n.mode&1?(ie=(r=ie)||n.memoizedState!==null,qe(e,t,n),ie=r):qe(e,t,n);break;default:qe(e,t,n)}}function qo(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new If),t.forEach(function(r){var l=Hf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=i),r&=~s}if(r=l,r=Y()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Mf(r/1960))-r,10e?16:e,lt===null)var r=!1;else{if(e=lt,lt=null,ul=0,I&6)throw Error(k(331));var l=I;for(I|=4,N=e.current;N!==null;){var s=N,i=s.child;if(N.flags&16){var a=s.deletions;if(a!==null){for(var u=0;uY()-Ui?Et(e,0):$i|=n),ve(e,t)}function vc(e,t){t===0&&(e.mode&1?(t=yr,yr<<=1,!(yr&130023424)&&(yr=4194304)):t=1);var n=ue();e=Xe(e,t),e!==null&&(nr(e,t,n),ve(e,n))}function Wf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),vc(e,n)}function Hf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(k(314))}r!==null&&r.delete(t),vc(e,n)}var yc;yc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||he.current)pe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pe=!1,Lf(e,t,n);pe=!!(e.flags&131072)}else pe=!1,B&&t.flags&1048576&&ku(t,el,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Mr(e,t),e=t.pendingProps;var l=ln(t,oe.current);tn(t,n),l=Ri(null,t,r,e,l,n);var s=Oi();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,me(r)?(s=!0,qr(t)):s=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,_i(t),l.updater=kl,t.stateNode=l,l._reactInternals=t,Ms(t,r,e,n),t=As(null,t,r,!0,s,n)):(t.tag=0,B&&s&&xi(t),ae(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Mr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Kf(r),e=Le(r,e),l){case 0:t=Us(null,t,r,e,n);break e;case 1:t=Go(null,t,r,e,n);break e;case 11:t=Qo(null,t,r,e,n);break e;case 14:t=Ko(null,t,r,Le(r.type,e),n);break e}throw Error(k(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Us(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Go(e,t,r,l,n);case 3:e:{if(ec(t),e===null)throw Error(k(387));r=t.pendingProps,s=t.memoizedState,l=s.element,_u(e,t),rl(t,r,null,n);var i=t.memoizedState;if(r=i.element,s.isDehydrated)if(s={element:r,isDehydrated:!1,cache:i.cache,pendingSuspenseBoundaries:i.pendingSuspenseBoundaries,transitions:i.transitions},t.updateQueue.baseState=s,t.memoizedState=s,t.flags&256){l=un(Error(k(423)),t),t=Yo(e,t,r,n,l);break e}else if(r!==l){l=un(Error(k(424)),t),t=Yo(e,t,r,n,l);break e}else for(ge=at(t.stateNode.containerInfo.firstChild),we=t,B=!0,Oe=null,n=Cu(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(sn(),r===l){t=Je(e,t,n);break e}ae(e,t,r,n)}t=t.child}return t;case 5:return Pu(t),e===null&&Is(t),r=t.type,l=t.pendingProps,s=e!==null?e.memoizedProps:null,i=l.children,Ts(r,l)?i=null:s!==null&&Ts(r,s)&&(t.flags|=32),bu(e,t),ae(e,t,i,n),t.child;case 6:return e===null&&Is(t),null;case 13:return tc(e,t,n);case 4:return Pi(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=on(t,null,r,n):ae(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Qo(e,t,r,l,n);case 7:return ae(e,t,t.pendingProps,n),t.child;case 8:return ae(e,t,t.pendingProps.children,n),t.child;case 12:return ae(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,s=t.memoizedProps,i=l.value,M(tl,r._currentValue),r._currentValue=i,s!==null)if(De(s.value,i)){if(s.children===l.children&&!he.current){t=Je(e,t,n);break e}}else for(s=t.child,s!==null&&(s.return=t);s!==null;){var a=s.dependencies;if(a!==null){i=s.child;for(var u=a.firstContext;u!==null;){if(u.context===r){if(s.tag===1){u=Ke(-1,n&-n),u.tag=2;var c=s.updateQueue;if(c!==null){c=c.shared;var v=c.pending;v===null?u.next=u:(u.next=v.next,v.next=u),c.pending=u}}s.lanes|=n,u=s.alternate,u!==null&&(u.lanes|=n),Fs(s.return,n,t),a.lanes|=n;break}u=u.next}}else if(s.tag===10)i=s.type===t.type?null:s.child;else if(s.tag===18){if(i=s.return,i===null)throw Error(k(341));i.lanes|=n,a=i.alternate,a!==null&&(a.lanes|=n),Fs(i,n,t),i=s.sibling}else i=s.child;if(i!==null)i.return=s;else for(i=s;i!==null;){if(i===t){i=null;break}if(s=i.sibling,s!==null){s.return=i.return,i=s;break}i=i.return}s=i}ae(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,tn(t,n),l=_e(l),r=r(l),t.flags|=1,ae(e,t,r,n),t.child;case 14:return r=t.type,l=Le(r,t.pendingProps),l=Le(r.type,l),Ko(e,t,r,l,n);case 15:return Zu(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Mr(e,t),t.tag=1,me(r)?(e=!0,qr(t)):e=!1,tn(t,n),Yu(t,r,l),Ms(t,r,l,n),As(null,t,r,!0,e,n);case 19:return nc(e,t,n);case 22:return qu(e,t,n)}throw Error(k(156,t.tag))};function gc(e,t){return Qa(e,t)}function Qf(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ce(e,t,n,r){return new Qf(e,t,n,r)}function Wi(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Kf(e){if(typeof e=="function")return Wi(e)?1:0;if(e!=null){if(e=e.$$typeof,e===ai)return 11;if(e===ui)return 14}return 2}function ft(e,t){var n=e.alternate;return n===null?(n=Ce(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function Ar(e,t,n,r,l,s){var i=2;if(r=e,typeof e=="function")Wi(e)&&(i=1);else if(typeof e=="string")i=5;else e:switch(e){case At:return _t(n.children,l,s,t);case oi:i=8,l|=8;break;case os:return e=Ce(12,n,t,l|2),e.elementType=os,e.lanes=s,e;case as:return e=Ce(13,n,t,l),e.elementType=as,e.lanes=s,e;case us:return e=Ce(19,n,t,l),e.elementType=us,e.lanes=s,e;case Pa:return Nl(n,l,s,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Ea:i=10;break e;case _a:i=9;break e;case ai:i=11;break e;case ui:i=14;break e;case be:i=16,r=null;break e}throw Error(k(130,e==null?e:typeof e,""))}return t=Ce(i,n,t,l),t.elementType=e,t.type=r,t.lanes=s,t}function _t(e,t,n,r){return e=Ce(7,e,r,t),e.lanes=n,e}function Nl(e,t,n,r){return e=Ce(22,e,r,t),e.elementType=Pa,e.lanes=n,e.stateNode={isHidden:!1},e}function rs(e,t,n){return e=Ce(6,e,null,t),e.lanes=n,e}function ls(e,t,n){return t=Ce(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Gf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=$l(0),this.expirationTimes=$l(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=$l(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Hi(e,t,n,r,l,s,i,a,u){return e=new Gf(e,t,n,a,u),t===1?(t=1,s===!0&&(t|=8)):t=0,s=Ce(3,null,null,t),e.current=s,s.stateNode=e,s.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},_i(s),e}function Yf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(jc)}catch(e){console.error(e)}}jc(),ja.exports=ke;var bf=ja.exports,Sc,ia=bf;Sc=ia.createRoot,ia.hydrateRoot;const Nc="bv.access",Yi="bv.refresh";function qs(e){try{return localStorage.getItem(e)||""}catch{return""}}function oa(e,t){try{t?localStorage.setItem(e,t):localStorage.removeItem(e)}catch{}}function ir(){return{access:qs(Nc),refresh:qs(Yi)}}function fl(e,t){oa(Nc,e),oa(Yi,t)}function or(){fl("","")}function ep(){return!!qs(Yi)}class Pt extends Error{constructor(t,n,r){super(r),this.status=t,this.code=n}}let Nn=null;async function Tl(){return Nn||(Nn=(async()=>{const{refresh:e}=ir();if(!e)throw new Pt(401,"unauthorized","Signed out.");const t=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refresh_token:e})});if(!t.ok)throw or(),new Pt(t.status,"unauthorized","Your session has ended. Sign in again.");const n=await t.json();return fl(n.access_token,n.refresh_token),n})().finally(()=>{Nn=null}),Nn)}async function D(e,t,n,r=!0){const{access:l}=ir(),s={};l&&(s.Authorization="Bearer "+l),n!==void 0&&(s["Content-Type"]="application/json");const i=await fetch(t,{method:e,headers:s,body:n===void 0?void 0:JSON.stringify(n)});if(i.status===204)return null;const a=await i.text();let u=null;try{u=a?JSON.parse(a):null}catch{}if(i.ok)return u;const c=(u==null?void 0:u.error)||"";if(c==="token_expired"&&r)return await Tl(),D(e,t,n,!1);throw i.status===401&&or(),new Pt(i.status,c,(u==null?void 0:u.message)||`Something went wrong (${i.status}).`)}async function Cc(e,t=!0){const{access:n}=ir(),r=await fetch(e,{headers:n?{Authorization:"Bearer "+n}:{}});if(r.ok)return URL.createObjectURL(await r.blob());let l=null;try{l=await r.json()}catch{}const s=(l==null?void 0:l.error)||"";if(s==="token_expired"&&t)return await Tl(),Cc(e,!1);throw r.status===401&&or(),new Pt(r.status,s,(l==null?void 0:l.message)||`That picture could not be loaded (${r.status}).`)}function aa(){const e=navigator.userAgent||"",t=/Windows/.test(e)?"Windows":/Mac OS X|Macintosh/.test(e)?"Mac":/Android/.test(e)?"Android":/iPhone|iPad/.test(e)?"iOS":"Unknown";return`${/Edg\//.test(e)?"Edge":/Chrome\//.test(e)?"Chrome":/Safari\//.test(e)?"Safari":/Firefox\//.test(e)?"Firefox":"browser"} on ${t}`}const jt=e=>{const t=new URLSearchParams;for(const[r,l]of Object.entries(e||{}))l!=null&&l!==""&&t.set(r,l);const n=t.toString();return n?"?"+n:""},$={async login(e,t){const n=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:e,password:t,device:aa()})}),r=await n.json().catch(()=>null);if(!n.ok)throw new Pt(n.status,(r==null?void 0:r.error)||"",(r==null?void 0:r.message)||"Could not sign in.");return fl(r.access_token,r.refresh_token),r.user},async previewInvitation(e){const t=await fetch("/api/auth/invitation"+jt({code:e})),n=await t.json().catch(()=>null);if(!t.ok)throw new Pt(t.status,(n==null?void 0:n.error)||"",(n==null?void 0:n.message)||"That invitation code is not valid.");return n},async register({code:e,full_name:t,password:n}){const r=await fetch("/api/auth/register",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({code:e,full_name:t,password:n,device:aa()})}),l=await r.json().catch(()=>null);if(!r.ok)throw new Pt(r.status,(l==null?void 0:l.error)||"",(l==null?void 0:l.message)||"Could not create the account.");return fl(l.access_token,l.refresh_token),l.user},async logout(){try{await D("POST","/api/auth/logout")}catch{}or()},me:()=>D("GET","/api/auth/me"),sites:()=>D("GET","/api/sites"),arrivals:e=>D("GET","/api/visits"+jt(e)),visitors:(e,t=50)=>D("GET","/api/visitors"+jt({q:e,limit:t})),visitorHistory:(e,t=50)=>D("GET",`/api/visitors/${encodeURIComponent(e)}/history`+jt({limit:t})),saveProfile:(e,t)=>D("PUT",`/api/visitors/${encodeURIComponent(e)}/profile`,t),footfall:e=>D("GET","/api/reports/footfall"+jt(e)),conversion:e=>D("GET","/api/reports/conversion"+jt(e)),cameras:()=>D("GET","/api/cameras"),cameraSnapshot:e=>Cc(e),createCamera:(e,t)=>D("POST",`/api/sites/${encodeURIComponent(e)}/cameras`,t),updateCamera:(e,t)=>D("PATCH",`/api/cameras/${encodeURIComponent(e)}`,t),deleteCamera:e=>D("DELETE",`/api/cameras/${encodeURIComponent(e)}`),checkCamera:(e,t,n)=>D("POST",`/api/cameras/${encodeURIComponent(e)}/check`,{kind:t,...n?{seconds:n}:{}}),siteCheck:e=>D("GET",`/api/sites/${encodeURIComponent(e)}/check`),enrolmentCode:(e,t)=>D("POST",`/api/sites/${encodeURIComponent(e)}/enrolment-code`,t||{}),ask:e=>D("POST","/api/assistant",{history:e}),clients:()=>D("GET","/api/admin/clients"),createClient:e=>D("POST","/api/admin/clients",e),team:()=>D("GET","/api/team"),updateMember:(e,t)=>D("PATCH",`/api/team/${encodeURIComponent(e)}`,t),invitations:()=>D("GET","/api/team/invitations"),invite:e=>D("POST","/api/team/invitations",e),revokeInvitation:e=>D("DELETE",`/api/team/invitations/${encodeURIComponent(e)}`),sessions:()=>D("GET","/api/auth/sessions"),revokeSession:e=>D("DELETE",`/api/auth/sessions/${encodeURIComponent(e)}`),signOutOthers:()=>D("POST","/api/auth/sessions/revoke-others")};function tp({cursor:e,siteId:t,onPage:n,onError:r,signal:l}){let s=!1,i=e||"";return(async()=>{for(;!s;){try{const{access:u}=ir(),c=await fetch("/api/visits/stream"+jt({cursor:i,site_id:t}),{headers:{Authorization:"Bearer "+u,Accept:"text/event-stream"},signal:l});if(c.status===401){await Tl();continue}if(!c.ok||!c.body)throw new Error("stream unavailable");const v=c.body.getReader(),m=new TextDecoder;let h="";for(;!s;){const{value:y,done:x}=await v.read();if(x)break;h+=m.decode(y,{stream:!0});let g;for(;(g=h.indexOf(` + +`))!==-1;){const T=h.slice(0,g);h=h.slice(g+2);for(const p of T.split(` +`))if(p.startsWith("id: "))i=p.slice(4).trim();else if(p.startsWith("data: "))try{n(JSON.parse(p.slice(6)))}catch{}}}}catch(u){if(s||l!=null&&l.aborted)return;r==null||r(u)}if(s)return;await new Promise(u=>setTimeout(u,3e3))}})(),()=>{s=!0}}function np({cameraId:e,img:t,onState:n,signal:r}){let l=!1;return(async()=>{for(;!l;){try{const{access:i}=ir(),a=await fetch(`/api/cameras/${e}/live`,{headers:{Authorization:"Bearer "+i,Accept:"text/event-stream"},signal:r});if(a.status===401){await Tl();continue}if(!a.ok||!a.body)throw new Error("live view unavailable");const u=a.body.getReader(),c=new TextDecoder;let v="";for(;!l;){const{value:m,done:h}=await u.read();if(h)break;v+=c.decode(m,{stream:!0});let y;for(;(y=v.indexOf(` + +`))!==-1;){const x=v.slice(0,y);v=v.slice(y+2);let g="message",T="";for(const p of x.split(` +`))p.startsWith("event: ")?g=p.slice(7).trim():p.startsWith("data: ")&&(T=p.slice(6));g==="frame"&&T?(t.current&&(t.current.src="data:image/jpeg;base64,"+T),n==null||n("live")):g==="waiting"&&(n==null||n("waiting"))}}}catch{if(l||r!=null&&r.aborted)return;n==null||n("reconnecting")}if(l)return;await new Promise(i=>setTimeout(i,1500))}})(),()=>{l=!0}}function rp({onSignedIn:e}){const[t,n]=j.useState(!1);return t?o.jsx(sp,{onSignedIn:e,onCancel:()=>n(!1)}):o.jsx(lp,{onSignedIn:e,onJoin:()=>n(!0)})}function lp({onSignedIn:e,onJoin:t}){const[n,r]=j.useState(""),[l,s]=j.useState(""),[i,a]=j.useState(""),[u,c]=j.useState(!1),v=async m=>{m.preventDefault(),c(!0),a("");try{await $.login(n.trim(),l),e(await $.me())}catch(h){a(h.message),c(!1)}};return o.jsx("div",{className:"signin",children:o.jsxs("form",{className:"card",onSubmit:v,children:[o.jsx("span",{className:"mark big","aria-hidden":"true"}),o.jsx("h1",{children:"Behavision"}),o.jsx("p",{className:"sub",children:"Sign in to your company account."}),o.jsxs("label",{children:["Email",o.jsx("input",{type:"email",value:n,autoComplete:"username",autoFocus:!0,required:!0,onChange:m=>r(m.target.value),placeholder:"you@company.com"})]}),o.jsxs("label",{children:["Password",o.jsx("input",{type:"password",value:l,autoComplete:"current-password",required:!0,onChange:m=>s(m.target.value)})]}),i&&o.jsx("p",{className:"error",role:"alert",children:i}),o.jsx("button",{className:"primary",disabled:u||!n||!l,children:u?"Signing in…":"Sign in"}),o.jsxs("p",{className:"foot",children:["Been invited? ",o.jsx("button",{type:"button",className:"linkish",onClick:t,children:"Use your invitation code"})]})]})})}function sp({onSignedIn:e,onCancel:t}){const[n,r]=j.useState(""),[l,s]=j.useState(null),[i,a]=j.useState(""),[u,c]=j.useState(""),[v,m]=j.useState(""),[h,y]=j.useState(""),[x,g]=j.useState(!1),T=async d=>{d.preventDefault(),g(!0),y("");try{const f=await $.previewInvitation(n.trim());s(f),a(f.full_name||"")}catch(f){y(f.message)}finally{g(!1)}},p=async d=>{if(d.preventDefault(),u!==v){y("Those two passwords are not the same.");return}g(!0),y("");try{const f=await $.register({code:n.trim(),full_name:i,password:u});e(f)}catch(f){y(f.message),g(!1)}};return o.jsx("div",{className:"signin",children:o.jsxs("form",{className:"card",onSubmit:l?p:T,children:[o.jsx("span",{className:"mark big","aria-hidden":"true"}),o.jsx("h1",{children:l?`Join ${l.client_name}`:"Behavision"}),l?o.jsxs(o.Fragment,{children:[o.jsxs("p",{className:"sub",children:["You are joining as ",o.jsx("b",{children:l.role}),", signing in with"," ",o.jsx("code",{children:l.email}),". Choose a password only you know."]}),o.jsxs("label",{children:["Your name",o.jsx("input",{value:i,onChange:d=>a(d.target.value),autoFocus:!0,autoComplete:"name"})]}),o.jsxs("label",{children:["Password",o.jsx("input",{type:"password",value:u,required:!0,minLength:8,autoComplete:"new-password",onChange:d=>c(d.target.value)}),o.jsx("span",{className:"hint",children:"At least 8 characters. Longer is the only thing that helps."})]}),o.jsxs("label",{children:["Password again",o.jsx("input",{type:"password",value:v,required:!0,autoComplete:"new-password",onChange:d=>m(d.target.value)})]}),h&&o.jsx("p",{className:"error",role:"alert",children:h}),o.jsx("button",{className:"primary",disabled:x||!u||!v,children:x?"Creating your account…":"Create account and sign in"})]}):o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"sub",children:"Enter the invitation code you were given."}),o.jsxs("label",{children:["Invitation code",o.jsx("input",{value:n,onChange:d=>r(d.target.value),autoFocus:!0,required:!0,autoComplete:"off",spellCheck:"false",placeholder:"ABCDEF-123456-GHIJKL-789012",className:"codefield"}),o.jsx("span",{className:"hint",children:"Dashes and capitals do not matter."})]}),h&&o.jsx("p",{className:"error",role:"alert",children:h}),o.jsx("button",{className:"primary",disabled:x||!n.trim(),children:x?"Checking…":"Continue"})]}),o.jsx("p",{className:"foot",children:o.jsx("button",{type:"button",className:"linkish",onClick:t,children:"Back to sign in"})})]})})}function It(e,t,n=[]){const[r,l]=j.useState(null),[s,i]=j.useState(null),[a,u]=j.useState(!0),c=j.useRef(!1),v=j.useRef(!0),m=j.useCallback(async()=>{if(!c.current){c.current=!0;try{const h=await e();if(!v.current)return;l(h),i(null)}catch(h){if(!v.current)return;i(h)}finally{c.current=!1,v.current&&u(!1)}}},n);return j.useEffect(()=>{if(v.current=!0,m(),!t)return()=>{v.current=!1};const h=setInterval(m,t);return()=>{v.current=!1,clearInterval(h)}},[m,t]),{data:r,error:s,loading:a,reload:m}}function ip(e){const[t,n]=j.useState(null);return j.useEffect(()=>{if(!e){n(null);return}let r=!0,l=null;return $.cameraSnapshot(e).then(s=>{if(!r){URL.revokeObjectURL(s);return}l=s,n(s)}).catch(()=>{r&&n(null)}),()=>{r=!1,l&&URL.revokeObjectURL(l)}},[e]),t}function Xi({image:e,url:t,alt:n}){const r=e?e.url:t,l=e&&!!e.auth||typeof r=="string"&&r.startsWith("/"),s=ip(l?r:null),i=l?s:r;return i?o.jsx("img",{src:i,alt:n,loading:"lazy"}):null}function op({site:e,onClose:t}){const[n,r]=j.useState(null),[l,s]=j.useState(!1),[i,a]=j.useState(""),u=async()=>{s(!0),a(""),r(null);try{r(await $.siteCheck(e.site_id))}catch(c){a(c.message)}finally{s(!1)}};return o.jsx("div",{className:"overlay",onClick:t,children:o.jsxs("aside",{className:"drawer",onClick:c=>c.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsxs("div",{children:[o.jsxs("h2",{children:["Is ",e.name," working?"]}),o.jsx("p",{className:"sub",children:"Checks the whole chain, from the shop’s PC to head office."})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Close"})]}),o.jsxs("div",{className:"drawer-body",children:[!n&&!l&&o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"sub",children:"Nothing is changed and no one is disturbed — this reads what the shop has already reported."}),o.jsx("button",{className:"primary",onClick:u,children:"Run the check"})]}),l&&o.jsxs("div",{className:"waiting",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),o.jsx("b",{children:"Checking…"})]}),i&&o.jsx("p",{className:"error",role:"alert",children:i}),n&&o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"banner "+(n.ok?"ok":"warn"),children:o.jsxs("div",{children:[o.jsx("b",{children:n.ok?"This shop is working.":"This shop needs attention."}),!n.ok&&o.jsx(o.Fragment,{children:" Work down the list — the first failure usually explains the rest."})]})}),o.jsx("ol",{className:"checklist",children:n.steps.map(c=>o.jsxs("li",{className:c.status,children:[o.jsx("span",{className:"mark","aria-hidden":"true",children:c.status==="pass"?"✓":c.status==="fail"?"✕":c.status==="warn"?"!":"–"}),o.jsxs("div",{children:[o.jsx("b",{children:c.name}),o.jsx("span",{className:"sub",children:c.detail}),c.advice&&o.jsx("span",{className:"advice-line",children:c.advice})]})]},c.name))}),o.jsx("button",{className:"ghost",onClick:u,children:"Check again"})]}),o.jsx(ap,{site:e})]})]})})}function ap({site:e}){const[t,n]=j.useState(null),[r,l]=j.useState(!1),[s,i]=j.useState(""),[a,u]=j.useState(""),c=async()=>{l(!0),i("");try{n(await $.enrolmentCode(e.site_id,{label:a.trim()}))}catch(v){i(v.message)}finally{l(!1)}};return o.jsxs("section",{className:"claim",children:[o.jsx("h3",{children:"Set up a shop PC"}),t?o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"code","aria-live":"polite",children:t.code}),o.jsxs("p",{className:"sub",children:["Copy this now — it cannot be shown again. It works once, and stops working ",cp(t.expires_at),"."]}),o.jsxs("div",{className:"row",children:[o.jsx("button",{className:"ghost",onClick:()=>up(t.code),children:"Copy"}),o.jsx("button",{className:"ghost",onClick:()=>n(null),children:"Done"})]})]}):o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"sub",children:"Creates a one-time code to type into Behavision on the shop’s computer. Use one for a new shop, a replacement PC, or a reinstall."}),o.jsxs("label",{className:"field",children:[o.jsx("span",{children:"What is this PC? (optional)"}),o.jsx("input",{value:a,placeholder:"counter PC",onChange:v=>u(v.target.value)})]}),s&&o.jsx("p",{className:"error",role:"alert",children:s}),o.jsx("button",{className:"ghost",onClick:c,disabled:r,children:r?"Creating…":"Create an installation code"})]})]})}function up(e){var t;try{(t=navigator.clipboard)==null||t.writeText(e)}catch{}}function cp(e){const t=new Date(e).getTime();if(Number.isNaN(t))return"shortly";const n=Math.round((t-Date.now())/864e5);return n<=0?"today":n===1?"tomorrow":`in ${n} days`}function dp(){const{data:e,error:t,loading:n}=It(()=>$.sites(),2e4,[]),{data:r}=It(()=>$.cameras(),6e4,[]),[l,s]=j.useState(null),i=e||[];if(n&&!e)return o.jsx(ar,{});if(t)return o.jsx(ur,{error:t});if(!i.length)return o.jsx(gp,{});const a=i.map(h=>{const y=(r||[]).filter(x=>x.site_id===h.site_id);return{site:h,cams:y,verdict:fp(h,y)}}),u=h=>a.filter(y=>y.verdict.tone===h).length,c=u("bad"),v=u("warn"),m=u("idle");return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Shops"}),o.jsxs("p",{className:"sub",children:[i.length," ",i.length===1?"shop":"shops",c>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"bad",children:[c," not working"]})]}),v>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"warn",children:[v," needing attention"]})]}),m>0&&o.jsxs(o.Fragment,{children:[" · ",m," not set up yet"]}),!c&&!v&&!m&&o.jsxs(o.Fragment,{children:[" · ",o.jsx("b",{className:"ok",children:"all working"})]})]})]}),o.jsx("div",{className:"grid sites",children:a.map(h=>o.jsx(hp,{site:h.site,cams:h.cams,verdict:h.verdict,onCheck:()=>s(h.site)},h.site.site_id))}),l&&o.jsx(op,{site:l,onClose:()=>s(null)})]})}function fp(e,t){const n=e.fraction_below_gate,r=e.cameras_total||t.length;return e.online?e.dropped>0?{tone:"bad",mark:"✕",words:`${e.dropped} visits lost and unrecoverable`}:r===0?{tone:"idle",mark:"+",headline:"Not set up",words:"No cameras set up yet"}:e.cameras_up===0?{tone:"bad",mark:"✕",words:"No cameras connected"}:e.cameras_up.5?{tone:"bad",mark:"✕",words:`${bs(n)} of faces too poor to recognise`}:n>.2?{tone:"warn",mark:"!",words:`${bs(n)} of faces too poor to recognise`}:e.queued>0?{tone:"warn",mark:"!",words:`${e.queued} visits waiting to upload`}:{tone:"ok",mark:"✓",words:`Working — ${r} ${r===1?"camera":"cameras"} connected`}:{tone:"bad",mark:"✕",headline:"Offline",words:`Offline — last heard from ${Ft(e.last_heartbeat_at)}`}}const pp={ok:"Working",warn:"Needs attention",bad:"Not working",idle:"Not set up"};function hp({site:e,cams:t,verdict:n,onCheck:r}){const l=e.fraction_below_gate,s=n.tone,i=n.headline||pp[s],a=mp(t),u=e.cameras_total||t.length;return o.jsxs("article",{className:"card site state-"+s,onClick:r,role:"button",tabIndex:0,onKeyDown:c=>c.key==="Enter"&&r(),children:[o.jsxs("div",{className:"shot",children:[a.url?o.jsx(Xi,{image:a,alt:`View inside ${e.name}`}):o.jsxs("div",{className:"noshot",children:[o.jsx(vp,{}),a.reason&&o.jsx("span",{children:a.reason})]}),o.jsxs("div",{className:"shot-over",children:[o.jsxs("div",{className:"shot-name",children:[o.jsx("b",{children:e.name}),o.jsx("span",{children:u>0?`${u} ${u===1?"camera":"cameras"}`:"No cameras yet"})]}),o.jsxs("span",{className:"status "+s,children:[o.jsx("i",{"aria-hidden":"true"}),i]})]}),a.at&&o.jsx("span",{className:"shot-age",children:Ft(a.at)})]}),o.jsxs("div",{className:"metrics",children:[o.jsx(ss,{label:"Cameras",value:u?`${e.cameras_up}/${u}`:"—",tone:u?e.cameras_up0?bs(1-l):"—",tone:l?l>.5?"bad":l>.2?"warn":"ok":"idle"}),o.jsx(ss,{label:"Last seen",value:Ft(e.last_heartbeat_at),tone:e.online?"ok":"bad"})]}),o.jsxs("div",{className:"verdict "+n.tone,children:[o.jsx("span",{className:"mark","aria-hidden":"true",children:n.mark}),o.jsx("span",{className:"words",children:n.words}),o.jsx("span",{className:"go","aria-hidden":"true",children:"→"})]})]})}function ss({label:e,value:t,tone:n}){return o.jsxs("div",{className:"metric",children:[o.jsx("b",{className:n,children:t}),o.jsx("span",{children:e})]})}function mp(e){var r;if(!e.length)return{};let t=null;for(const l of e)!((r=l.snapshot)!=null&&r.available)||!l.snapshot.url||(!t||(l.snapshot_at||"")>(t.snapshot_at||""))&&(t=l);return t?{...t.snapshot,at:t.snapshot_at}:{reason:e.map(l=>{var s;return(s=l.snapshot)==null?void 0:s.reason}).find(Boolean)||"No picture from this shop yet."}}function vp(){return o.jsxs("svg",{className:"shopmark",viewBox:"0 0 40 32","aria-hidden":"true",children:[o.jsx("path",{d:"M4 12h32v18H4z"}),o.jsx("path",{d:"M2 12l4-8h28l4 8"}),o.jsx("path",{d:"M15 30v-9h10v9"})]})}function bs(e){return`${Math.round(e*100)}%`}function Ft(e){if(!e)return"never";const t=new Date(e).getTime();if(Number.isNaN(t))return"—";const n=Math.max(0,(Date.now()-t)/1e3);if(n<90)return"just now";const r=Math.round(n/60);if(r<60)return`${r} min ago`;const l=Math.round(r/60);return l<48?`${l} h ago`:`${Math.round(l/24)} days ago`}function yp(e){if(!e)return"never";const t=new Date(e).getTime();if(Number.isNaN(t))return"—";const n=(t-Date.now())/1e3;if(n<=0)return"expired";const r=Math.round(n/60);if(r<60)return`in ${r} min`;const l=Math.round(r/60);return l<48?`in ${l} h`:`in ${Math.round(l/24)} days`}function ar(){return o.jsxs("div",{className:"state",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),"Loading…"]})}function ur({error:e}){return o.jsx("div",{className:"state",children:o.jsx("p",{className:"error",role:"alert",children:e.message})})}function gp(){return o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"No shops yet"}),o.jsx("p",{className:"sub",children:"A shop appears here once its PC has been claimed with an enrolment code."})]})}const ua=60;function wp(){const[e,t]=j.useState([]),[n,r]=j.useState("connecting"),[l,s]=j.useState(null),i=j.useRef(new Set);return j.useEffect(()=>{const a=new AbortController;let u=()=>{};return(async()=>{try{const c=await $.arrivals({limit:30}),v=c.arrivals||[];v.forEach(m=>i.current.add(m.visit_id)),t(v.slice().reverse()),r("live"),u=tp({cursor:c.cursor,signal:a.signal,onPage:m=>{const h=(m.arrivals||[]).filter(y=>!i.current.has(y.visit_id));h.length&&(h.forEach(y=>i.current.add(y.visit_id)),r("live"),t(y=>[...h.reverse(),...y].slice(0,ua)))},onError:()=>r("reconnecting")})}catch(c){s(c)}})(),()=>{a.abort(),u()}},[]),l?o.jsx(ur,{error:l}):n==="connecting"&&!e.length?o.jsx(ar,{}):o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Live"}),o.jsxs("p",{className:"sub",children:[o.jsx("span",{className:"dot "+(n==="live"?"ok":"warn"),"aria-hidden":"true"}),n==="live"?"Connected":"Reconnecting…"," · ","last ",ua," arrivals"]})]}),e.length===0?o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"Nobody yet"}),o.jsx("p",{className:"sub",children:"Arrivals appear here the moment a camera recognises someone."})]}):o.jsx("ul",{className:"arrivals",children:e.map(a=>o.jsx(xp,{a},a.visit_id))})]})}function xp({a:e}){const t=e.name||e.label||"Unidentified";return o.jsxs("li",{className:"card arrival",children:[o.jsx(kp,{image:e.image,name:t}),o.jsxs("div",{className:"who-col",children:[o.jsx("strong",{children:t}),o.jsxs("span",{className:"sub",children:[e.site,e.camera_id?` · ${e.camera_id}`:""," · ",Ft(e.occurred_at)]}),e.attributes&&o.jsx(Sp,{attrs:e.attributes})]}),e.is_new_visitor?o.jsx("span",{className:"pill new",children:"New"}):o.jsx("span",{className:"pill",children:"Returning"})]})}function kp({image:e,name:t}){return e!=null&&e.available?o.jsx("span",{className:"face",children:o.jsx(Xi,{image:e,alt:""})}):o.jsx("span",{className:"face initials",title:(e==null?void 0:e.reason)||"","aria-hidden":"true",children:jp(t)})}function jp(e){const t=String(e).trim().split(/\s+/).filter(Boolean);return t.length?t.length===1?t[0].slice(0,2).toUpperCase():(t[0][0]+t[t.length-1][0]).toUpperCase():"?"}function Sp({attrs:e}){const t=[];return e.gender&&t.push(e.gender),e.age&&t.push(`~${Math.round(e.age)}`),e.emotion&&t.push(e.emotion),t.length?o.jsx("span",{className:"attrs",children:t.join(" · ")}):null}function Np({camera:e,onClose:t}){const n=j.useRef(null),[r,l]=j.useState("waiting"),[s,i]=j.useState(!1);j.useEffect(()=>{const c=new AbortController,v=np({cameraId:e.id,img:n,onState:h=>{l(h),h==="live"&&i(!1)},signal:c.signal}),m=setTimeout(()=>i(!0),12e3);return()=>{v(),c.abort(),clearTimeout(m)}},[e.id]);const a=e.connected===!1?"This camera was not connecting when the shop PC last reported. Check it is powered on and reachable on the shop’s network.":e.connected==null?"The shop PC has not reported on this camera yet. It may still be starting up.":"The shop PC is not sending frames. It may be offline, or its Behavision app may not be running.",u=s&&r!=="live"?a:{waiting:"Asking the shop PC…",live:"Live",reconnecting:"Reconnecting…"}[r];return o.jsx("div",{className:"overlay",onClick:t,children:o.jsxs("aside",{className:"drawer live",onClick:c=>c.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsxs("div",{children:[o.jsx("h2",{children:e.label}),o.jsx("p",{className:"sub",children:e.site})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Close"})]}),o.jsxs("div",{className:"drawer-body",children:[o.jsxs("div",{className:"liveshot",children:[o.jsx("img",{ref:n,alt:`Live view from ${e.label}`}),r!=="live"&&o.jsx("div",{className:"livewait",children:o.jsx("span",{children:s?"No picture yet":u})})]}),o.jsx("p",{className:"hint",style:{marginTop:10},children:r==="live"?"Live. The shop only uploads while this view is open.":u})]})]})})}const Br=[{id:"hikvision",label:"Hikvision",path:"/Streaming/Channels/101",note:"Channel 1, main stream. Use /Streaming/Channels/102 for the lower-quality sub stream."},{id:"dahua",label:"Dahua",path:"/cam/realmonitor?channel=1&subtype=0",note:"Channel 1, main stream. subtype=1 is the sub stream."},{id:"cpplus",label:"CP Plus",path:"/cam/realmonitor?channel=1&subtype=0",note:"CP Plus cameras use the Dahua stream path."},{id:"uniview",label:"Uniview",path:"/media/video1",note:"Some older Uniview models use /video1 instead."},{id:"tplink",label:"TP-Link / Tapo",path:"/stream1",note:"Tapo cameras need a separate camera account created in the Tapo app — your Tapo login will not work."},{id:"reolink",label:"Reolink",path:"/h264Preview_01_main",note:"Use /h264Preview_01_sub for the lower-quality stream."},{id:"amcrest",label:"Amcrest",path:"/cam/realmonitor?channel=1&subtype=0",note:"Amcrest cameras use the Dahua stream path."},{id:"axis",label:"Axis",path:"/axis-media/media.amp",note:""},{id:"onvif",label:"Other (ONVIF)",path:"/onvif1",note:"Many generic cameras answer here. If it does not work, look for “RTSP” in the camera’s own app."},{id:"manual",label:"I know the path",path:"",note:""}],ca=e=>Br.find(t=>t.id===e)||Br[Br.length-1],Cp=["Camera","Connection","Test","Walk past"];function Ep({sites:e,existing:t,onClose:n,onSaved:r}){var d;const l=!(t!=null&&t.id),[s,i]=j.useState(l?0:2),[a,u]=j.useState(t!=null&&t.id?t:null),[c,v]=j.useState({site_id:(t==null?void 0:t.site_id)||((d=e[0])==null?void 0:d.site_id)||"",make:"hikvision",label:(t==null?void 0:t.label)||"",host:(t==null?void 0:t.host)||"",port:(t==null?void 0:t.port)||554,path:(t==null?void 0:t.path)||"/Streaming/Channels/101",username:(t==null?void 0:t.username)||"",password:""}),[m,h]=j.useState(!1),[y,x]=j.useState(""),g=f=>w=>v(S=>({...S,[f]:w.target.value})),T=f=>{const w=ca(f.target.value);v(S=>({...S,make:w.id,path:w.path||S.path}))},p=async()=>{h(!0),x("");const f={};for(const[w,S]of Object.entries(c))w==="site_id"||w==="make"||S===""||S==null||(f[w]=w==="port"?Number(S):S);try{const w=a!=null&&a.id?await $.updateCamera(a.id,f):await $.createCamera(c.site_id,f);u(w),i(2),r==null||r(w,{keepOpen:!0})}catch(w){x(w.message)}finally{h(!1)}};return o.jsx("div",{className:"overlay",onClick:n,children:o.jsxs("aside",{className:"drawer wizard",onClick:f=>f.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsxs("div",{children:[o.jsx("h2",{children:l?"Set up a camera":(a==null?void 0:a.label)||(t==null?void 0:t.label)}),o.jsx("p",{className:"sub",children:s<2?"The shop’s PC connects to the camera — nothing needs opening to the internet.":"Prove it works before you rely on it."})]}),o.jsx("button",{className:"ghost",onClick:n,children:"Close"})]}),o.jsx("ol",{className:"steps",children:Cp.map((f,w)=>o.jsxs("li",{className:w===s?"now":wo.jsx("option",{value:f.site_id,children:f.name},f.site_id))})]}),o.jsxs("label",{children:["What should staff call it?",o.jsx("input",{value:c.label,onChange:g("label"),placeholder:"Entrance",autoFocus:!0}),o.jsx("span",{className:"hint",children:"Visits are recorded against this name, so it cannot be changed later."})]}),o.jsxs("label",{children:["Make of camera",o.jsx("select",{value:c.make,onChange:T,children:Br.map(f=>o.jsx("option",{value:f.id,children:f.label},f.id))}),o.jsx("span",{className:"hint",children:ca(c.make).note||"This only fills in the stream path for you. You can change it on the next step."})]}),o.jsx("button",{className:"primary",disabled:!c.label.trim(),onClick:()=>i(1),children:"Next"})]}),s===1&&o.jsxs("div",{className:"drawer-body",children:[o.jsxs("label",{children:["Camera’s address on the shop’s network",o.jsx("input",{value:c.host,onChange:g("host"),placeholder:"192.168.0.138",autoFocus:!0}),o.jsx("span",{className:"hint",children:"Not a website address. It usually starts 192.168. — look in the camera’s own app, on its label, or in your router’s device list."})]}),o.jsxs("div",{className:"pair",children:[o.jsxs("label",{children:["Port",o.jsx("input",{type:"number",value:c.port,onChange:g("port")})]}),o.jsxs("label",{children:["Stream path",o.jsx("input",{value:c.path,onChange:g("path")})]})]}),o.jsxs("label",{children:["Camera username",o.jsx("input",{value:c.username,onChange:g("username"),placeholder:"admin",name:"camera-account",autoComplete:"off",autoCorrect:"off",autoCapitalize:"none",spellCheck:"false"}),o.jsx("span",{className:"hint",children:"The camera’s own login, not your Behavision one."})]}),o.jsxs("label",{children:["Camera password",o.jsx("input",{type:"password",value:c.password,onChange:g("password"),name:"camera-secret",autoComplete:"new-password",placeholder:a!=null&&a.has_password?"(unchanged)":""})]}),y&&o.jsx("p",{className:"error",role:"alert",children:y}),o.jsxs("div",{className:"pair",children:[o.jsx("button",{className:"ghost",onClick:()=>i(0),children:"Back"}),o.jsx("button",{className:"primary",disabled:m||!c.host.trim(),onClick:p,children:m?"Saving…":"Save and test"})]})]}),s>=2&&a&&o.jsx(_p,{camera:a,step:s,onStep:i,onUpdated:f=>{u(f),r==null||r(f,{keepOpen:!0})},onEdit:()=>i(1),onDone:n})]})})}function _p({camera:e,step:t,onStep:n,onUpdated:r,onEdit:l,onDone:s}){const[i,a]=j.useState(e),[u,c]=j.useState(!1),[v,m]=j.useState(""),h=j.useRef(null);j.useEffect(()=>a(e),[e]);const y=i.check||{},x=y.state==="requested"||y.state==="running";j.useEffect(()=>{if(!x)return;let p=!0;const d=async()=>{try{const w=(await $.cameras()).find(S=>S.id===i.id);p&&w&&(a(w),r==null||r(w))}catch{}};return h.current=setInterval(d,4e3),()=>{p=!1,clearInterval(h.current)}},[x,i.id]);const g=async(p,d)=>{c(!0),m("");try{const f=await $.checkCamera(i.id,p,d);a(f),r==null||r(f),n(p==="placement"?3:2)}catch(f){m(f.message)}finally{c(!1)}},T=y.kind==="connection"&&y.state==="done"&&y.ok;return o.jsx(o.Fragment,{children:o.jsxs("div",{className:"drawer-body",children:[t===2?o.jsxs(o.Fragment,{children:[o.jsx("h3",{children:"Can the shop’s PC reach this camera?"}),o.jsx("p",{className:"sub",children:"The PC opens the stream once and takes a single picture. Nothing is recorded."})]}):o.jsxs(o.Fragment,{children:[o.jsx("h3",{children:"Can it actually recognise a face?"}),o.jsxs("p",{className:"sub",children:["Someone needs to ",o.jsx("b",{children:"walk through the camera’s view and out of it"}),", the way a customer would. Standing still measures nothing — the check scores the best view of each person as they leave the frame, which is what recognition really uses."]})]}),x&&o.jsxs("div",{className:"waiting",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),o.jsxs("div",{children:[o.jsx("b",{children:y.state==="running"?"Running on the shop’s PC…":"Waiting for the shop’s PC…"}),o.jsx("span",{className:"sub",children:y.state==="running"?"This takes about "+(y.seconds||25)+" seconds.":"It picks up the request within a couple of minutes."})]})]}),y.state==="done"&&o.jsx(Pp,{check:y}),v&&o.jsx("p",{className:"error",role:"alert",children:v}),o.jsx("div",{className:"pair",children:t===2?o.jsxs(o.Fragment,{children:[o.jsx("button",{className:"ghost",onClick:l,children:"Change details"}),o.jsx("button",{className:"primary",disabled:u||x,onClick:()=>g("connection"),children:y.state==="done"?"Test again":"Test connection"})]}):o.jsxs(o.Fragment,{children:[o.jsx("button",{className:"ghost",onClick:()=>n(2),children:"Back"}),o.jsx("button",{className:"primary",disabled:u||x,onClick:()=>g("placement",25),children:y.state==="done"?"Check again":"Start walk-past check"})]})}),t===2&&T&&!x&&o.jsx("button",{className:"primary",onClick:()=>n(3),children:"Next: prove it can recognise faces"}),t===3&&y.state==="done"&&!x&&o.jsx("button",{className:"ghost",onClick:s,children:"Finish"})]})})}function Pp({check:e}){var n,r,l;const t=e.ok?"ok":e.verdict==="marginal"?"warn":"bad";return o.jsxs("div",{className:"outcome "+t,children:[o.jsxs("div",{className:"outcome-head",children:[o.jsx("span",{className:"pill "+t,children:e.ok?"Working":"Not ready"}),o.jsx("b",{children:e.headline||(e.ok?"Working":"Could not be verified")})]}),((n=e.image)==null?void 0:n.available)&&o.jsx("img",{className:"proof",src:e.image.url,alt:"The view from this camera",loading:"lazy"}),((r=e.advice)==null?void 0:r.length)>0&&o.jsx("ul",{className:"advice",children:e.advice.map((s,i)=>o.jsx("li",{children:s},i))}),((l=e.detail)==null?void 0:l.faces)!=null&&o.jsxs("p",{className:"sub",children:[e.detail.faces," ",e.detail.faces===1?"person":"people"," walked past during the check."]})]})}function Tp(e){const t=e.check||{};return t.state==="requested"||t.state==="running"?{tone:"idle",mark:"…",words:"Checking now"}:t.state!=="done"?{tone:"idle",mark:"?",words:"Not checked yet"}:t.kind==="placement"?t.ok?{tone:"ok",mark:"✓",words:"Recognises faces here"}:{tone:"bad",mark:"✕",words:t.headline||"Cannot recognise faces here"}:t.ok?{tone:"warn",mark:"!",words:"Stream works — faces not checked yet"}:{tone:"bad",mark:"✕",words:t.headline||"Could not reach the camera"}}function zp({user:e}){const{data:t,error:n,loading:r,reload:l}=It(()=>$.cameras(),2e4,[]),{data:s}=It(()=>$.sites(),0,[]),[i,a]=j.useState(null),[u,c]=j.useState(null),v=["admin","owner","manager"].includes(e.role),m=t||[],h=m.filter(g=>g.connected).length,y=m.filter(g=>g.connected===!1).length,x=m.filter(g=>g.connected==null).length;return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Cameras"}),o.jsxs("p",{className:"sub",children:[m.length," ",m.length===1?"camera":"cameras",h>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"ok",children:[h," connected"]})]}),y>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"bad",children:[y," down"]})]}),x>0&&o.jsxs(o.Fragment,{children:[" · ",x," waiting for the shop PC"]})]}),v&&o.jsx("button",{className:"primary",onClick:()=>a({}),children:"Set up a camera"})]}),r&&!t?o.jsx(ar,{}):n?o.jsx(ur,{error:n}):m.length===0?o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"No cameras yet"}),o.jsx("p",{className:"sub",children:"Add one here and the shop’s PC will pick it up within a couple of minutes. Cameras already set up on a shop PC appear here on their own."})]}):o.jsx("div",{className:"grid cams",children:m.map(g=>o.jsx(Lp,{cam:g,canEdit:v,onEdit:()=>a(g),onWatch:()=>c(g)},g.id))}),i&&o.jsx(Ep,{existing:i.id?i:null,sites:s||[],onClose:()=>{a(null),l()},onSaved:(g,T)=>{T!=null&&T.keepOpen||a(null),l()}}),u&&o.jsx(Np,{camera:u,onClose:()=>c(null)})]})}function Lp({cam:e,canEdit:t,onEdit:n,onWatch:r}){var a,u;const l=e.connected==null?"idle":e.connected?"ok":"bad",s=e.connected==null?"Waiting for the shop PC":e.connected?"Connected":"Not connecting",i=Tp(e);return o.jsxs("article",{className:"card cam state-"+l,onClick:t?n:void 0,role:t?"button":void 0,tabIndex:t?0:void 0,onKeyDown:c=>t&&c.key==="Enter"&&n(),children:[o.jsxs("div",{className:"shot",children:[(a=e.snapshot)!=null&&a.available?o.jsx(Xi,{image:e.snapshot,alt:`View from ${e.label}`}):o.jsxs("div",{className:"noshot",children:[o.jsx("span",{className:"lens","aria-hidden":"true"}),((u=e.snapshot)==null?void 0:u.reason)||"No picture yet."]}),o.jsxs("div",{className:"shot-over",children:[o.jsxs("div",{className:"shot-name",children:[o.jsx("b",{children:e.label||e.camera_id}),o.jsx("span",{children:e.site})]}),o.jsxs("span",{className:"status "+l,children:[o.jsx("i",{"aria-hidden":"true"}),s]})]}),e.snapshot_at&&o.jsx("span",{className:"shot-age",children:Ft(e.snapshot_at)}),o.jsxs("button",{className:"watch",title:"Watch this camera now",onClick:c=>{c.stopPropagation(),r()},children:[o.jsx("i",{"aria-hidden":"true"}),"Live"]})]}),o.jsxs("div",{className:"verdict "+i.tone,children:[o.jsx("span",{className:"mark","aria-hidden":"true",children:i.mark}),o.jsx("span",{className:"words",children:i.words}),t&&o.jsx("span",{className:"go","aria-hidden":"true",children:"→"})]})]})}const Rp=["Is everything working today?","Why is footfall low at Chennai?","How many people came in last week?","Which cameras still need checking?"];function Op({open:e,onClose:t}){const[n,r]=j.useState([]),[l,s]=j.useState(""),[i,a]=j.useState(!1),[u,c]=j.useState(!1),v=j.useRef(null),m=j.useRef(null);j.useEffect(()=>{var y;e&&((y=m.current)==null||y.focus())},[e]),j.useEffect(()=>{var y;(y=v.current)==null||y.scrollIntoView({behavior:"smooth",block:"end"})},[n,i]);const h=async y=>{const x=(y??l).trim();if(!x||i)return;const g=[...n,{role:"user",text:x}];r(g),s(""),a(!0);try{const T=await $.ask(g);r(p=>[...p,{role:"assistant",text:T.text,used:T.used}])}catch(T){T.code==="assistant_off"?c(!0):r(p=>[...p,{role:"assistant",text:T.message,failed:!0}])}finally{a(!1)}};return e?o.jsxs("aside",{className:"assistant",role:"complementary","aria-label":"Assistant",children:[o.jsxs("header",{className:"assistant-head",children:[o.jsxs("div",{children:[o.jsx("b",{children:"Ask Behavision"}),o.jsx("span",{className:"sub",children:"It reads your shops’ own data to answer."})]}),o.jsx("button",{className:"ghost",onClick:t,"aria-label":"Close assistant",children:"✕"})]}),o.jsxs("div",{className:"assistant-body",children:[u?o.jsx("p",{className:"sub pad",children:"The assistant is not switched on for this server."}):n.length===0?o.jsxs("div",{className:"suggest",children:[o.jsx("p",{className:"sub",children:"Try asking:"}),Rp.map(y=>o.jsx("button",{className:"chip",onClick:()=>h(y),children:y},y))]}):n.map((y,x)=>{var g;return o.jsxs("div",{className:"bubble "+y.role+(y.failed?" failed":""),children:[y.text,((g=y.used)==null?void 0:g.length)>0&&o.jsxs("span",{className:"used",children:["looked at: ",y.used.map(Ip).join(", ")]})]},x)}),i&&o.jsxs("div",{className:"bubble assistant-thinking",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"})," Looking…"]}),o.jsx("div",{ref:v})]}),!u&&o.jsxs("form",{className:"assistant-ask",onSubmit:y=>{y.preventDefault(),h()},children:[o.jsx("input",{ref:m,value:l,onChange:y=>s(y.target.value),placeholder:"Ask about your shops…",disabled:i}),o.jsx("button",{className:"primary",disabled:i||!l.trim(),children:"Ask"})]})]}):null}function Ip(e){return{list_shops:"your shops",check_shop:"a shop check",list_cameras:"your cameras",check_camera:"a camera check",footfall:"footfall",sales:"sales",find_customer:"customer records"}[e]||e}function Fp(){const{data:e,error:t,loading:n,reload:r}=It(()=>$.clients(),0,[]),[l,s]=j.useState(!1),[i,a]=j.useState(null),u=e||[];return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Companies"}),o.jsx("button",{className:"primary",onClick:()=>{a(null),s(!0)},children:"New company"})]}),i&&o.jsx(Mp,{result:i,onDismiss:()=>a(null)}),n&&!e?o.jsx(ar,{}):t?o.jsx(ur,{error:t}):u.length===0?o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"No companies yet"}),o.jsx("p",{className:"sub",children:"Create one, and its owner can sign in straight away."})]}):o.jsx("div",{className:"tablewrap",children:o.jsxs("table",{className:"rows",children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"Company"}),o.jsx("th",{children:"Short name"}),o.jsx("th",{children:"Sites"}),o.jsx("th",{children:"People"}),o.jsx("th",{children:"Created"})]})}),o.jsx("tbody",{children:u.map(c=>o.jsxs("tr",{children:[o.jsx("td",{children:o.jsx("strong",{children:c.name})}),o.jsx("td",{children:o.jsx("code",{children:c.slug})}),o.jsx("td",{className:"num",children:c.sites}),o.jsx("td",{className:"num",children:c.users}),o.jsx("td",{className:"sub",children:Ft(c.created_at)})]},c.id))})]})}),l&&o.jsx(Dp,{onClose:()=>s(!1),onCreated:c=>{s(!1),a(c),r()}})]})}function Dp({onClose:e,onCreated:t}){const[n,r]=j.useState({company_name:"",owner_name:"",owner_email:"",password:""}),[l,s]=j.useState(!1),[i,a]=j.useState(""),u=v=>m=>r({...n,[v]:m.target.value}),c=async v=>{v.preventDefault(),s(!0),a("");try{t(await $.createClient(n))}catch(m){a(m.message),s(!1)}};return o.jsx("div",{className:"overlay",onClick:e,children:o.jsxs("aside",{className:"drawer narrow",onClick:v=>v.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsx("h2",{children:"New company"}),o.jsx("button",{className:"ghost",onClick:e,children:"Close"})]}),o.jsxs("form",{className:"drawer-body",onSubmit:c,children:[o.jsxs("label",{children:["Company name",o.jsx("input",{value:n.company_name,onChange:u("company_name"),required:!0,autoFocus:!0})]}),o.jsxs("label",{children:["Owner’s name",o.jsx("input",{value:n.owner_name,onChange:u("owner_name")})]}),o.jsxs("label",{children:["Owner’s email",o.jsx("input",{type:"email",value:n.owner_email,onChange:u("owner_email"),required:!0})]}),o.jsxs("label",{children:["Password",o.jsx("input",{type:"text",value:n.password,onChange:u("password"),placeholder:"Leave empty to generate one"}),o.jsx("span",{className:"hint",children:"Generated is better — a password you invent for someone else ends up weak and sent over chat."})]}),i&&o.jsx("p",{className:"error",role:"alert",children:i}),o.jsx("button",{className:"primary",disabled:l,children:l?"Creating…":"Create company"})]})]})})}function Mp({result:e,onDismiss:t}){return o.jsxs("div",{className:"banner ok credentials",children:[o.jsxs("div",{children:[o.jsxs("b",{children:[e.slug," created."]})," These sign-in details are shown once and cannot be recovered. Send them to the owner now.",o.jsxs("dl",{className:"creds",children:[o.jsxs("div",{children:[o.jsx("dt",{children:"Email"}),o.jsx("dd",{children:o.jsx("code",{children:e.owner_email})})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"Password"}),o.jsx("dd",{children:o.jsx("code",{children:e.password})})]})]})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Done"})]})}function $p({user:e}){const t=It(()=>$.team(),0,[]),n=It(()=>$.invitations(),0,[]),[r,l]=j.useState(!1),[s,i]=j.useState(null),[a,u]=j.useState(""),[c,v]=j.useState(""),m=e.role==="owner"||e.role==="manager",h=t.data||[],y=n.data||[],x=async(g,T)=>{u(g),v("");try{await $.updateMember(g,T),t.reload()}catch(p){v(p.message)}finally{u("")}};return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Team"}),m&&o.jsx("button",{className:"primary",onClick:()=>{i(null),l(!0)},children:"Invite someone"})]}),s&&o.jsx(Ap,{invite:s,onDismiss:()=>i(null)}),c&&o.jsx("p",{className:"error",role:"alert",children:c}),t.loading&&!t.data?o.jsx(ar,{}):t.error?o.jsx(ur,{error:t.error}):o.jsx("div",{className:"tablewrap",children:o.jsxs("table",{className:"rows",children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"Name"}),o.jsx("th",{children:"Email"}),o.jsx("th",{children:"Role"}),o.jsx("th",{children:"Last signed in"}),m&&o.jsx("th",{})]})}),o.jsx("tbody",{children:h.map(g=>o.jsxs("tr",{className:g.active?"":"inactive",children:[o.jsxs("td",{children:[o.jsx("strong",{children:g.full_name||"—"}),!g.active&&o.jsx("span",{className:"pill muted",children:"No access"})]}),o.jsx("td",{children:o.jsx("code",{children:g.email})}),o.jsx("td",{children:m&&g.id!==e.id?o.jsxs("select",{value:g.role,disabled:a===g.id,onChange:T=>x(g.id,{role:T.target.value}),children:[o.jsx("option",{value:"staff",children:"Staff"}),o.jsx("option",{value:"manager",children:"Manager"}),e.role==="owner"&&o.jsx("option",{value:"owner",children:"Owner"})]}):o.jsx("span",{className:"role",children:g.role})}),o.jsx("td",{className:"sub",children:g.last_login_at?Ft(g.last_login_at):"Never"}),m&&o.jsx("td",{className:"right",children:g.id===e.id?null:g.active?o.jsx("button",{className:"ghost danger",disabled:a===g.id,onClick:()=>x(g.id,{active:!1}),children:"Remove access"}):o.jsx("button",{className:"ghost",disabled:a===g.id,onClick:()=>x(g.id,{active:!0}),children:"Restore"})})]},g.id))})]})}),m&&y.length>0&&o.jsxs("section",{className:"pending",children:[o.jsx("h2",{children:"Waiting to join"}),o.jsx("ul",{className:"invites",children:y.map(g=>o.jsxs("li",{className:"card invite",children:[o.jsxs("div",{children:[o.jsx("strong",{children:g.email}),o.jsxs("span",{className:"sub",children:["invited as ",g.role,g.invited_by?` by ${g.invited_by}`:""," · expires ",yp(g.expires_at)]})]}),o.jsx("button",{className:"ghost danger",onClick:async()=>{await $.revokeInvitation(g.id),n.reload()},children:"Withdraw"})]},g.id))})]}),r&&o.jsx(Up,{canMintOwner:e.role==="owner",onClose:()=>l(!1),onDone:g=>{l(!1),i(g),n.reload()}})]})}function Up({canMintOwner:e,onClose:t,onDone:n}){const[r,l]=j.useState({email:"",full_name:"",role:"staff"}),[s,i]=j.useState(!1),[a,u]=j.useState(""),c=m=>h=>l({...r,[m]:h.target.value}),v=async m=>{m.preventDefault(),i(!0),u("");try{n(await $.invite(r))}catch(h){u(h.message),i(!1)}};return o.jsx("div",{className:"overlay",onClick:t,children:o.jsxs("aside",{className:"drawer narrow",onClick:m=>m.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsx("h2",{children:"Invite someone"}),o.jsx("button",{className:"ghost",onClick:t,children:"Close"})]}),o.jsxs("form",{className:"drawer-body",onSubmit:v,children:[o.jsxs("label",{children:["Their email",o.jsx("input",{type:"email",value:r.email,onChange:c("email"),required:!0,autoFocus:!0,autoComplete:"off",name:"invitee"}),o.jsx("span",{className:"hint",children:"This is the address they will sign in with, and it is fixed by the invitation — passing the code on cannot make it somebody else’s account."})]}),o.jsxs("label",{children:["Their name",o.jsx("input",{value:r.full_name,onChange:c("full_name"),autoComplete:"off",name:"invitee-name"})]}),o.jsxs("label",{children:["Role",o.jsxs("select",{value:r.role,onChange:c("role"),children:[o.jsx("option",{value:"staff",children:"Staff — see customers and shops"}),o.jsx("option",{value:"manager",children:"Manager — also set up cameras and invite people"}),e&&o.jsx("option",{value:"owner",children:"Owner — full control"})]})]}),a&&o.jsx("p",{className:"error",role:"alert",children:a}),o.jsx("button",{className:"primary",disabled:s,children:s?"Creating…":"Create invitation"})]})]})})}function Ap({invite:e,onDismiss:t}){return o.jsxs("div",{className:"banner ok credentials",children:[o.jsxs("div",{children:[o.jsxs("b",{children:["Invitation for ",e.email,"."]})," Give them this code. It is shown once, works once, and cannot be recovered.",o.jsxs("dl",{className:"creds",children:[o.jsxs("div",{children:[o.jsx("dt",{children:"Code"}),o.jsx("dd",{children:o.jsx("code",{className:"big",children:e.code})})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"Role"}),o.jsx("dd",{children:e.role})]})]}),o.jsx("p",{className:"sub",children:"They open the app, choose “I have an invitation code”, and pick their own password. Nobody else ever sees it."})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Done"})]})}const Bp=[{id:"sites",label:"Shops",View:dp},{id:"live",label:"Live",View:wp},{id:"cameras",label:"Cameras",View:zp},{id:"team",label:"Team",View:$p}],Vp=[{id:"clients",label:"Companies",View:Fp}];function Wp(){const[e,t]=j.useState(null),[n,r]=j.useState(!0),[l,s]=j.useState("sites"),[i,a]=j.useState(!1);if(j.useEffect(()=>{(async()=>{if(ep())try{t(await $.me())}catch{or()}r(!1)})()},[]),n)return o.jsxs("div",{className:"boot",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),"Loading…"]});if(!e)return o.jsx(rp,{onSignedIn:t});const u=e.role==="admin"&&!e.client_id,c=u?Vp:Bp,v=c.find(y=>y.id===l)||c[0],m=v.View,h=async()=>{await $.logout(),t(null)};return o.jsxs("div",{className:"app",children:[o.jsxs("header",{className:"topbar",children:[o.jsxs("div",{className:"brand",children:[o.jsx("span",{className:"mark","aria-hidden":"true"}),o.jsxs("div",{children:[o.jsx("strong",{children:"Behavision"}),o.jsx("span",{className:"org",children:e.client_name||"Loyaly platform"})]})]}),o.jsx("nav",{className:"tabs",children:c.map(y=>o.jsx("button",{onClick:()=>s(y.id),"aria-current":v.id===y.id?"page":void 0,children:y.label},y.id))}),o.jsxs("div",{className:"who",children:[o.jsxs("button",{className:"ask-btn"+(i?" on":""),onClick:()=>a(y=>!y),children:[o.jsx("span",{"aria-hidden":"true",children:"✳"})," Ask"]}),o.jsx("span",{className:"name",children:e.full_name||e.email}),o.jsx("span",{className:"role",children:u?"platform admin":e.role}),o.jsx("button",{className:"ghost",onClick:h,children:"Sign out"})]})]}),o.jsxs("div",{className:"with-assistant"+(i?" open":""),children:[o.jsx("main",{className:"page",children:o.jsx(m,{user:e})}),o.jsx(Op,{open:i,onClose:()=>a(!1)})]})]})}Sc(document.getElementById("root")).render(o.jsx(Wp,{})); diff --git a/server/internal/web/dist/assets/index-tRretU9M.js b/server/internal/web/dist/assets/index-tRretU9M.js deleted file mode 100644 index 4095cab..0000000 --- a/server/internal/web/dist/assets/index-tRretU9M.js +++ /dev/null @@ -1,46 +0,0 @@ -(function(){const t=document.createElement("link").relList;if(t&&t.supports&&t.supports("modulepreload"))return;for(const l of document.querySelectorAll('link[rel="modulepreload"]'))r(l);new MutationObserver(l=>{for(const i of l)if(i.type==="childList")for(const s of i.addedNodes)s.tagName==="LINK"&&s.rel==="modulepreload"&&r(s)}).observe(document,{childList:!0,subtree:!0});function n(l){const i={};return l.integrity&&(i.integrity=l.integrity),l.referrerPolicy&&(i.referrerPolicy=l.referrerPolicy),l.crossOrigin==="use-credentials"?i.credentials="include":l.crossOrigin==="anonymous"?i.credentials="omit":i.credentials="same-origin",i}function r(l){if(l.ep)return;l.ep=!0;const i=n(l);fetch(l.href,i)}})();var au={exports:{}},cl={},cu={exports:{}},R={};/** - * @license React - * react.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var tr=Symbol.for("react.element"),Ec=Symbol.for("react.portal"),_c=Symbol.for("react.fragment"),Pc=Symbol.for("react.strict_mode"),Tc=Symbol.for("react.profiler"),zc=Symbol.for("react.provider"),Lc=Symbol.for("react.context"),Rc=Symbol.for("react.forward_ref"),Oc=Symbol.for("react.suspense"),Ic=Symbol.for("react.memo"),Fc=Symbol.for("react.lazy"),Zs=Symbol.iterator;function Dc(e){return e===null||typeof e!="object"?null:(e=Zs&&e[Zs]||e["@@iterator"],typeof e=="function"?e:null)}var du={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},fu=Object.assign,pu={};function an(e,t,n){this.props=e,this.context=t,this.refs=pu,this.updater=n||du}an.prototype.isReactComponent={};an.prototype.setState=function(e,t){if(typeof e!="object"&&typeof e!="function"&&e!=null)throw Error("setState(...): takes an object of state variables to update or a function which returns an object of state variables.");this.updater.enqueueSetState(this,e,t,"setState")};an.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,"forceUpdate")};function hu(){}hu.prototype=an.prototype;function bi(e,t,n){this.props=e,this.context=t,this.refs=pu,this.updater=n||du}var es=bi.prototype=new hu;es.constructor=bi;fu(es,an.prototype);es.isPureReactComponent=!0;var Js=Array.isArray,mu=Object.prototype.hasOwnProperty,ts={current:null},vu={key:!0,ref:!0,__self:!0,__source:!0};function yu(e,t,n){var r,l={},i=null,s=null;if(t!=null)for(r in t.ref!==void 0&&(s=t.ref),t.key!==void 0&&(i=""+t.key),t)mu.call(t,r)&&!vu.hasOwnProperty(r)&&(l[r]=t[r]);var u=arguments.length-2;if(u===1)l.children=n;else if(1>>1,q=C[Q];if(0>>1;Ql(Ll,L))wtl(ar,Ll)?(C[Q]=ar,C[wt]=L,Q=wt):(C[Q]=Ll,C[gt]=L,Q=gt);else if(wtl(ar,L))C[Q]=ar,C[wt]=L,Q=wt;else break e}}return z}function l(C,z){var L=C.sortIndex-z.sortIndex;return L!==0?L:C.id-z.id}if(typeof performance=="object"&&typeof performance.now=="function"){var i=performance;e.unstable_now=function(){return i.now()}}else{var s=Date,u=s.now();e.unstable_now=function(){return s.now()-u}}var a=[],c=[],m=1,v=null,h=3,y=!1,k=!1,w=!1,T=typeof setTimeout=="function"?setTimeout:null,p=typeof clearTimeout=="function"?clearTimeout:null,d=typeof setImmediate<"u"?setImmediate:null;typeof navigator<"u"&&navigator.scheduling!==void 0&&navigator.scheduling.isInputPending!==void 0&&navigator.scheduling.isInputPending.bind(navigator.scheduling);function f(C){for(var z=n(c);z!==null;){if(z.callback===null)r(c);else if(z.startTime<=C)r(c),z.sortIndex=z.expirationTime,t(a,z);else break;z=n(c)}}function g(C){if(w=!1,f(C),!k)if(n(a)!==null)k=!0,Tl(S);else{var z=n(c);z!==null&&zl(g,z.startTime-C)}}function S(C,z){k=!1,w&&(w=!1,p(P),P=-1),y=!0;var L=h;try{for(f(z),v=n(a);v!==null&&(!(v.expirationTime>z)||C&&!Te());){var Q=v.callback;if(typeof Q=="function"){v.callback=null,h=v.priorityLevel;var q=Q(v.expirationTime<=z);z=e.unstable_now(),typeof q=="function"?v.callback=q:v===n(a)&&r(a),f(z)}else r(a);v=n(a)}if(v!==null)var ur=!0;else{var gt=n(c);gt!==null&&zl(g,gt.startTime-z),ur=!1}return ur}finally{v=null,h=L,y=!1}}var E=!1,_=null,P=-1,H=5,O=-1;function Te(){return!(e.unstable_now()-OC||125Q?(C.sortIndex=L,t(c,C),n(a)===null&&C===n(c)&&(w?(p(P),P=-1):w=!0,zl(g,L-Q))):(C.sortIndex=q,t(a,C),k||y||(k=!0,Tl(S))),C},e.unstable_shouldYield=Te,e.unstable_wrapCallback=function(C){var z=h;return function(){var L=h;h=z;try{return C.apply(this,arguments)}finally{h=L}}}})(Su);xu.exports=Su;var Gc=xu.exports;/** - * @license React - * react-dom.production.min.js - * - * Copyright (c) Facebook, Inc. and its affiliates. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */var Yc=N,ke=Gc;function x(e){for(var t="https://reactjs.org/docs/error-decoder.html?invariant="+e,n=1;n"u"||typeof window.document>"u"||typeof window.document.createElement>"u"),ii=Object.prototype.hasOwnProperty,Xc=/^[:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD][:A-Z_a-z\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u02FF\u0370-\u037D\u037F-\u1FFF\u200C-\u200D\u2070-\u218F\u2C00-\u2FEF\u3001-\uD7FF\uF900-\uFDCF\uFDF0-\uFFFD\-.0-9\u00B7\u0300-\u036F\u203F-\u2040]*$/,bs={},eo={};function Zc(e){return ii.call(eo,e)?!0:ii.call(bs,e)?!1:Xc.test(e)?eo[e]=!0:(bs[e]=!0,!1)}function Jc(e,t,n,r){if(n!==null&&n.type===0)return!1;switch(typeof t){case"function":case"symbol":return!0;case"boolean":return r?!1:n!==null?!n.acceptsBooleans:(e=e.toLowerCase().slice(0,5),e!=="data-"&&e!=="aria-");default:return!1}}function qc(e,t,n,r){if(t===null||typeof t>"u"||Jc(e,t,n,r))return!0;if(r)return!1;if(n!==null)switch(n.type){case 3:return!t;case 4:return t===!1;case 5:return isNaN(t);case 6:return isNaN(t)||1>t}return!1}function de(e,t,n,r,l,i,s){this.acceptsBooleans=t===2||t===3||t===4,this.attributeName=r,this.attributeNamespace=l,this.mustUseProperty=n,this.propertyName=e,this.type=t,this.sanitizeURL=i,this.removeEmptyString=s}var re={};"children dangerouslySetInnerHTML defaultValue defaultChecked innerHTML suppressContentEditableWarning suppressHydrationWarning style".split(" ").forEach(function(e){re[e]=new de(e,0,!1,e,null,!1,!1)});[["acceptCharset","accept-charset"],["className","class"],["htmlFor","for"],["httpEquiv","http-equiv"]].forEach(function(e){var t=e[0];re[t]=new de(t,1,!1,e[1],null,!1,!1)});["contentEditable","draggable","spellCheck","value"].forEach(function(e){re[e]=new de(e,2,!1,e.toLowerCase(),null,!1,!1)});["autoReverse","externalResourcesRequired","focusable","preserveAlpha"].forEach(function(e){re[e]=new de(e,2,!1,e,null,!1,!1)});"allowFullScreen async autoFocus autoPlay controls default defer disabled disablePictureInPicture disableRemotePlayback formNoValidate hidden loop noModule noValidate open playsInline readOnly required reversed scoped seamless itemScope".split(" ").forEach(function(e){re[e]=new de(e,3,!1,e.toLowerCase(),null,!1,!1)});["checked","multiple","muted","selected"].forEach(function(e){re[e]=new de(e,3,!0,e,null,!1,!1)});["capture","download"].forEach(function(e){re[e]=new de(e,4,!1,e,null,!1,!1)});["cols","rows","size","span"].forEach(function(e){re[e]=new de(e,6,!1,e,null,!1,!1)});["rowSpan","start"].forEach(function(e){re[e]=new de(e,5,!1,e.toLowerCase(),null,!1,!1)});var rs=/[\-:]([a-z])/g;function ls(e){return e[1].toUpperCase()}"accent-height alignment-baseline arabic-form baseline-shift cap-height clip-path clip-rule color-interpolation color-interpolation-filters color-profile color-rendering dominant-baseline enable-background fill-opacity fill-rule flood-color flood-opacity font-family font-size font-size-adjust font-stretch font-style font-variant font-weight glyph-name glyph-orientation-horizontal glyph-orientation-vertical horiz-adv-x horiz-origin-x image-rendering letter-spacing lighting-color marker-end marker-mid marker-start overline-position overline-thickness paint-order panose-1 pointer-events rendering-intent shape-rendering stop-color stop-opacity strikethrough-position strikethrough-thickness stroke-dasharray stroke-dashoffset stroke-linecap stroke-linejoin stroke-miterlimit stroke-opacity stroke-width text-anchor text-decoration text-rendering underline-position underline-thickness unicode-bidi unicode-range units-per-em v-alphabetic v-hanging v-ideographic v-mathematical vector-effect vert-adv-y vert-origin-x vert-origin-y word-spacing writing-mode xmlns:xlink x-height".split(" ").forEach(function(e){var t=e.replace(rs,ls);re[t]=new de(t,1,!1,e,null,!1,!1)});"xlink:actuate xlink:arcrole xlink:role xlink:show xlink:title xlink:type".split(" ").forEach(function(e){var t=e.replace(rs,ls);re[t]=new de(t,1,!1,e,"http://www.w3.org/1999/xlink",!1,!1)});["xml:base","xml:lang","xml:space"].forEach(function(e){var t=e.replace(rs,ls);re[t]=new de(t,1,!1,e,"http://www.w3.org/XML/1998/namespace",!1,!1)});["tabIndex","crossOrigin"].forEach(function(e){re[e]=new de(e,1,!1,e.toLowerCase(),null,!1,!1)});re.xlinkHref=new de("xlinkHref",1,!1,"xlink:href","http://www.w3.org/1999/xlink",!0,!1);["src","href","action","formAction"].forEach(function(e){re[e]=new de(e,1,!1,e.toLowerCase(),null,!0,!0)});function is(e,t,n,r){var l=re.hasOwnProperty(t)?re[t]:null;(l!==null?l.type!==0:r||!(2u||l[s]!==i[u]){var a=` -`+l[s].replace(" at new "," at ");return e.displayName&&a.includes("")&&(a=a.replace("",e.displayName)),a}while(1<=s&&0<=u);break}}}finally{Il=!1,Error.prepareStackTrace=n}return(e=e?e.displayName||e.name:"")?jn(e):""}function bc(e){switch(e.tag){case 5:return jn(e.type);case 16:return jn("Lazy");case 13:return jn("Suspense");case 19:return jn("SuspenseList");case 0:case 2:case 15:return e=Fl(e.type,!1),e;case 11:return e=Fl(e.type.render,!1),e;case 1:return e=Fl(e.type,!0),e;default:return""}}function ai(e){if(e==null)return null;if(typeof e=="function")return e.displayName||e.name||null;if(typeof e=="string")return e;switch(e){case Mt:return"Fragment";case Dt:return"Portal";case si:return"Profiler";case ss:return"StrictMode";case oi:return"Suspense";case ui:return"SuspenseList"}if(typeof e=="object")switch(e.$$typeof){case Nu:return(e.displayName||"Context")+".Consumer";case Cu:return(e._context.displayName||"Context")+".Provider";case os:var t=e.render;return e=e.displayName,e||(e=t.displayName||t.name||"",e=e!==""?"ForwardRef("+e+")":"ForwardRef"),e;case us:return t=e.displayName||null,t!==null?t:ai(e.type)||"Memo";case be:t=e._payload,e=e._init;try{return ai(e(t))}catch{}}return null}function ed(e){var t=e.type;switch(e.tag){case 24:return"Cache";case 9:return(t.displayName||"Context")+".Consumer";case 10:return(t._context.displayName||"Context")+".Provider";case 18:return"DehydratedFragment";case 11:return e=t.render,e=e.displayName||e.name||"",t.displayName||(e!==""?"ForwardRef("+e+")":"ForwardRef");case 7:return"Fragment";case 5:return t;case 4:return"Portal";case 3:return"Root";case 6:return"Text";case 16:return ai(t);case 8:return t===ss?"StrictMode":"Mode";case 22:return"Offscreen";case 12:return"Profiler";case 21:return"Scope";case 13:return"Suspense";case 19:return"SuspenseList";case 25:return"TracingMarker";case 1:case 0:case 17:case 2:case 14:case 15:if(typeof t=="function")return t.displayName||t.name||null;if(typeof t=="string")return t}return null}function pt(e){switch(typeof e){case"boolean":case"number":case"string":case"undefined":return e;case"object":return e;default:return""}}function _u(e){var t=e.type;return(e=e.nodeName)&&e.toLowerCase()==="input"&&(t==="checkbox"||t==="radio")}function td(e){var t=_u(e)?"checked":"value",n=Object.getOwnPropertyDescriptor(e.constructor.prototype,t),r=""+e[t];if(!e.hasOwnProperty(t)&&typeof n<"u"&&typeof n.get=="function"&&typeof n.set=="function"){var l=n.get,i=n.set;return Object.defineProperty(e,t,{configurable:!0,get:function(){return l.call(this)},set:function(s){r=""+s,i.call(this,s)}}),Object.defineProperty(e,t,{enumerable:n.enumerable}),{getValue:function(){return r},setValue:function(s){r=""+s},stopTracking:function(){e._valueTracker=null,delete e[t]}}}}function fr(e){e._valueTracker||(e._valueTracker=td(e))}function Pu(e){if(!e)return!1;var t=e._valueTracker;if(!t)return!0;var n=t.getValue(),r="";return e&&(r=_u(e)?e.checked?"true":"false":e.value),e=r,e!==n?(t.setValue(e),!0):!1}function Ar(e){if(e=e||(typeof document<"u"?document:void 0),typeof e>"u")return null;try{return e.activeElement||e.body}catch{return e.body}}function ci(e,t){var n=t.checked;return V({},t,{defaultChecked:void 0,defaultValue:void 0,value:void 0,checked:n??e._wrapperState.initialChecked})}function no(e,t){var n=t.defaultValue==null?"":t.defaultValue,r=t.checked!=null?t.checked:t.defaultChecked;n=pt(t.value!=null?t.value:n),e._wrapperState={initialChecked:r,initialValue:n,controlled:t.type==="checkbox"||t.type==="radio"?t.checked!=null:t.value!=null}}function Tu(e,t){t=t.checked,t!=null&&is(e,"checked",t,!1)}function di(e,t){Tu(e,t);var n=pt(t.value),r=t.type;if(n!=null)r==="number"?(n===0&&e.value===""||e.value!=n)&&(e.value=""+n):e.value!==""+n&&(e.value=""+n);else if(r==="submit"||r==="reset"){e.removeAttribute("value");return}t.hasOwnProperty("value")?fi(e,t.type,n):t.hasOwnProperty("defaultValue")&&fi(e,t.type,pt(t.defaultValue)),t.checked==null&&t.defaultChecked!=null&&(e.defaultChecked=!!t.defaultChecked)}function ro(e,t,n){if(t.hasOwnProperty("value")||t.hasOwnProperty("defaultValue")){var r=t.type;if(!(r!=="submit"&&r!=="reset"||t.value!==void 0&&t.value!==null))return;t=""+e._wrapperState.initialValue,n||t===e.value||(e.value=t),e.defaultValue=t}n=e.name,n!==""&&(e.name=""),e.defaultChecked=!!e._wrapperState.initialChecked,n!==""&&(e.name=n)}function fi(e,t,n){(t!=="number"||Ar(e.ownerDocument)!==e)&&(n==null?e.defaultValue=""+e._wrapperState.initialValue:e.defaultValue!==""+n&&(e.defaultValue=""+n))}var Cn=Array.isArray;function Yt(e,t,n,r){if(e=e.options,t){t={};for(var l=0;l"+t.valueOf().toString()+"",t=pr.firstChild;e.firstChild;)e.removeChild(e.firstChild);for(;t.firstChild;)e.appendChild(t.firstChild)}});function Mn(e,t){if(t){var n=e.firstChild;if(n&&n===e.lastChild&&n.nodeType===3){n.nodeValue=t;return}}e.textContent=t}var _n={animationIterationCount:!0,aspectRatio:!0,borderImageOutset:!0,borderImageSlice:!0,borderImageWidth:!0,boxFlex:!0,boxFlexGroup:!0,boxOrdinalGroup:!0,columnCount:!0,columns:!0,flex:!0,flexGrow:!0,flexPositive:!0,flexShrink:!0,flexNegative:!0,flexOrder:!0,gridArea:!0,gridRow:!0,gridRowEnd:!0,gridRowSpan:!0,gridRowStart:!0,gridColumn:!0,gridColumnEnd:!0,gridColumnSpan:!0,gridColumnStart:!0,fontWeight:!0,lineClamp:!0,lineHeight:!0,opacity:!0,order:!0,orphans:!0,tabSize:!0,widows:!0,zIndex:!0,zoom:!0,fillOpacity:!0,floodOpacity:!0,stopOpacity:!0,strokeDasharray:!0,strokeDashoffset:!0,strokeMiterlimit:!0,strokeOpacity:!0,strokeWidth:!0},nd=["Webkit","ms","Moz","O"];Object.keys(_n).forEach(function(e){nd.forEach(function(t){t=t+e.charAt(0).toUpperCase()+e.substring(1),_n[t]=_n[e]})});function Ou(e,t,n){return t==null||typeof t=="boolean"||t===""?"":n||typeof t!="number"||t===0||_n.hasOwnProperty(e)&&_n[e]?(""+t).trim():t+"px"}function Iu(e,t){e=e.style;for(var n in t)if(t.hasOwnProperty(n)){var r=n.indexOf("--")===0,l=Ou(n,t[n],r);n==="float"&&(n="cssFloat"),r?e.setProperty(n,l):e[n]=l}}var rd=V({menuitem:!0},{area:!0,base:!0,br:!0,col:!0,embed:!0,hr:!0,img:!0,input:!0,keygen:!0,link:!0,meta:!0,param:!0,source:!0,track:!0,wbr:!0});function mi(e,t){if(t){if(rd[e]&&(t.children!=null||t.dangerouslySetInnerHTML!=null))throw Error(x(137,e));if(t.dangerouslySetInnerHTML!=null){if(t.children!=null)throw Error(x(60));if(typeof t.dangerouslySetInnerHTML!="object"||!("__html"in t.dangerouslySetInnerHTML))throw Error(x(61))}if(t.style!=null&&typeof t.style!="object")throw Error(x(62))}}function vi(e,t){if(e.indexOf("-")===-1)return typeof t.is=="string";switch(e){case"annotation-xml":case"color-profile":case"font-face":case"font-face-src":case"font-face-uri":case"font-face-format":case"font-face-name":case"missing-glyph":return!1;default:return!0}}var yi=null;function as(e){return e=e.target||e.srcElement||window,e.correspondingUseElement&&(e=e.correspondingUseElement),e.nodeType===3?e.parentNode:e}var gi=null,Xt=null,Zt=null;function so(e){if(e=lr(e)){if(typeof gi!="function")throw Error(x(280));var t=e.stateNode;t&&(t=ml(t),gi(e.stateNode,e.type,t))}}function Fu(e){Xt?Zt?Zt.push(e):Zt=[e]:Xt=e}function Du(){if(Xt){var e=Xt,t=Zt;if(Zt=Xt=null,so(e),t)for(e=0;e>>=0,e===0?32:31-(hd(e)/md|0)|0}var hr=64,mr=4194304;function Nn(e){switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return e&4194240;case 4194304:case 8388608:case 16777216:case 33554432:case 67108864:return e&130023424;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 1073741824;default:return e}}function Hr(e,t){var n=e.pendingLanes;if(n===0)return 0;var r=0,l=e.suspendedLanes,i=e.pingedLanes,s=n&268435455;if(s!==0){var u=s&~l;u!==0?r=Nn(u):(i&=s,i!==0&&(r=Nn(i)))}else s=n&~l,s!==0?r=Nn(s):i!==0&&(r=Nn(i));if(r===0)return 0;if(t!==0&&t!==r&&!(t&l)&&(l=r&-r,i=t&-t,l>=i||l===16&&(i&4194240)!==0))return t;if(r&4&&(r|=n&16),t=e.entangledLanes,t!==0)for(e=e.entanglements,t&=r;0n;n++)t.push(e);return t}function nr(e,t,n){e.pendingLanes|=t,t!==536870912&&(e.suspendedLanes=0,e.pingedLanes=0),e=e.eventTimes,t=31-Ie(t),e[t]=n}function wd(e,t){var n=e.pendingLanes&~t;e.pendingLanes=t,e.suspendedLanes=0,e.pingedLanes=0,e.expiredLanes&=t,e.mutableReadLanes&=t,e.entangledLanes&=t,t=e.entanglements;var r=e.eventTimes;for(e=e.expirationTimes;0=Tn),vo=" ",yo=!1;function na(e,t){switch(e){case"keyup":return Gd.indexOf(t.keyCode)!==-1;case"keydown":return t.keyCode!==229;case"keypress":case"mousedown":case"focusout":return!0;default:return!1}}function ra(e){return e=e.detail,typeof e=="object"&&"data"in e?e.data:null}var $t=!1;function Xd(e,t){switch(e){case"compositionend":return ra(t);case"keypress":return t.which!==32?null:(yo=!0,vo);case"textInput":return e=t.data,e===vo&&yo?null:e;default:return null}}function Zd(e,t){if($t)return e==="compositionend"||!ys&&na(e,t)?(e=ea(),zr=hs=rt=null,$t=!1,e):null;switch(e){case"paste":return null;case"keypress":if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}e:{for(;n;){if(n.nextSibling){n=n.nextSibling;break e}n=n.parentNode}n=void 0}n=xo(n)}}function oa(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?oa(e,t.parentNode):"contains"in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function ua(){for(var e=window,t=Ar();t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href=="string"}catch{n=!1}if(n)e=t.contentWindow;else break;t=Ar(e.document)}return t}function gs(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t==="input"&&(e.type==="text"||e.type==="search"||e.type==="tel"||e.type==="url"||e.type==="password")||t==="textarea"||e.contentEditable==="true")}function sf(e){var t=ua(),n=e.focusedElem,r=e.selectionRange;if(t!==n&&n&&n.ownerDocument&&oa(n.ownerDocument.documentElement,n)){if(r!==null&&gs(n)){if(t=r.start,e=r.end,e===void 0&&(e=t),"selectionStart"in n)n.selectionStart=t,n.selectionEnd=Math.min(e,n.value.length);else if(e=(t=n.ownerDocument||document)&&t.defaultView||window,e.getSelection){e=e.getSelection();var l=n.textContent.length,i=Math.min(r.start,l);r=r.end===void 0?i:Math.min(r.end,l),!e.extend&&i>r&&(l=r,r=i,i=l),l=So(n,i);var s=So(n,r);l&&s&&(e.rangeCount!==1||e.anchorNode!==l.node||e.anchorOffset!==l.offset||e.focusNode!==s.node||e.focusOffset!==s.offset)&&(t=t.createRange(),t.setStart(l.node,l.offset),e.removeAllRanges(),i>r?(e.addRange(t),e.extend(s.node,s.offset)):(t.setEnd(s.node,s.offset),e.addRange(t)))}}for(t=[],e=n;e=e.parentNode;)e.nodeType===1&&t.push({element:e,left:e.scrollLeft,top:e.scrollTop});for(typeof n.focus=="function"&&n.focus(),n=0;n=document.documentMode,Ut=null,Ci=null,Ln=null,Ni=!1;function jo(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ni||Ut==null||Ut!==Ar(r)||(r=Ut,"selectionStart"in r&&gs(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Ln&&Wn(Ln,r)||(Ln=r,r=Gr(Ci,"onSelect"),0Vt||(e.current=Li[Vt],Li[Vt]=null,Vt--)}function D(e,t){Vt++,Li[Vt]=e.current,e.current=t}var ht={},oe=vt(ht),he=vt(!1),_t=ht;function tn(e,t){var n=e.type.contextTypes;if(!n)return ht;var r=e.stateNode;if(r&&r.__reactInternalMemoizedUnmaskedChildContext===t)return r.__reactInternalMemoizedMaskedChildContext;var l={},i;for(i in n)l[i]=t[i];return r&&(e=e.stateNode,e.__reactInternalMemoizedUnmaskedChildContext=t,e.__reactInternalMemoizedMaskedChildContext=l),l}function me(e){return e=e.childContextTypes,e!=null}function Xr(){$(he),$(oe)}function zo(e,t,n){if(oe.current!==ht)throw Error(x(168));D(oe,t),D(he,n)}function ya(e,t,n){var r=e.stateNode;if(t=t.childContextTypes,typeof r.getChildContext!="function")return n;r=r.getChildContext();for(var l in r)if(!(l in t))throw Error(x(108,ed(e)||"Unknown",l));return V({},n,r)}function Zr(e){return e=(e=e.stateNode)&&e.__reactInternalMemoizedMergedChildContext||ht,_t=oe.current,D(oe,e),D(he,he.current),!0}function Lo(e,t,n){var r=e.stateNode;if(!r)throw Error(x(169));n?(e=ya(e,t,_t),r.__reactInternalMemoizedMergedChildContext=e,$(he),$(oe),D(oe,e)):$(he),D(he,n)}var We=null,vl=!1,Xl=!1;function ga(e){We===null?We=[e]:We.push(e)}function gf(e){vl=!0,ga(e)}function yt(){if(!Xl&&We!==null){Xl=!0;var e=0,t=F;try{var n=We;for(F=1;e>=s,l-=s,He=1<<32-Ie(t)+l|n<P?(H=_,_=null):H=_.sibling;var O=h(p,_,f[P],g);if(O===null){_===null&&(_=H);break}e&&_&&O.alternate===null&&t(p,_),d=i(O,d,P),E===null?S=O:E.sibling=O,E=O,_=H}if(P===f.length)return n(p,_),U&&kt(p,P),S;if(_===null){for(;PP?(H=_,_=null):H=_.sibling;var Te=h(p,_,O.value,g);if(Te===null){_===null&&(_=H);break}e&&_&&Te.alternate===null&&t(p,_),d=i(Te,d,P),E===null?S=Te:E.sibling=Te,E=Te,_=H}if(O.done)return n(p,_),U&&kt(p,P),S;if(_===null){for(;!O.done;P++,O=f.next())O=v(p,O.value,g),O!==null&&(d=i(O,d,P),E===null?S=O:E.sibling=O,E=O);return U&&kt(p,P),S}for(_=r(p,_);!O.done;P++,O=f.next())O=y(_,p,P,O.value,g),O!==null&&(e&&O.alternate!==null&&_.delete(O.key===null?P:O.key),d=i(O,d,P),E===null?S=O:E.sibling=O,E=O);return e&&_.forEach(function(fn){return t(p,fn)}),U&&kt(p,P),S}function T(p,d,f,g){if(typeof f=="object"&&f!==null&&f.type===Mt&&f.key===null&&(f=f.props.children),typeof f=="object"&&f!==null){switch(f.$$typeof){case dr:e:{for(var S=f.key,E=d;E!==null;){if(E.key===S){if(S=f.type,S===Mt){if(E.tag===7){n(p,E.sibling),d=l(E,f.props.children),d.return=p,p=d;break e}}else if(E.elementType===S||typeof S=="object"&&S!==null&&S.$$typeof===be&&Io(S)===E.type){n(p,E.sibling),d=l(E,f.props),d.ref=wn(p,E,f),d.return=p,p=d;break e}n(p,E);break}else t(p,E);E=E.sibling}f.type===Mt?(d=Et(f.props.children,p.mode,g,f.key),d.return=p,p=d):(g=$r(f.type,f.key,f.props,null,p.mode,g),g.ref=wn(p,d,f),g.return=p,p=g)}return s(p);case Dt:e:{for(E=f.key;d!==null;){if(d.key===E)if(d.tag===4&&d.stateNode.containerInfo===f.containerInfo&&d.stateNode.implementation===f.implementation){n(p,d.sibling),d=l(d,f.children||[]),d.return=p,p=d;break e}else{n(p,d);break}else t(p,d);d=d.sibling}d=ri(f,p.mode,g),d.return=p,p=d}return s(p);case be:return E=f._init,T(p,d,E(f._payload),g)}if(Cn(f))return k(p,d,f,g);if(hn(f))return w(p,d,f,g);Sr(p,f)}return typeof f=="string"&&f!==""||typeof f=="number"?(f=""+f,d!==null&&d.tag===6?(n(p,d.sibling),d=l(d,f),d.return=p,p=d):(n(p,d),d=ni(f,p.mode,g),d.return=p,p=d),s(p)):n(p,d)}return T}var rn=Sa(!0),ja=Sa(!1),br=vt(null),el=null,Qt=null,Ss=null;function js(){Ss=Qt=el=null}function Cs(e){var t=br.current;$(br),e._currentValue=t}function Ii(e,t,n){for(;e!==null;){var r=e.alternate;if((e.childLanes&t)!==t?(e.childLanes|=t,r!==null&&(r.childLanes|=t)):r!==null&&(r.childLanes&t)!==t&&(r.childLanes|=t),e===n)break;e=e.return}}function qt(e,t){el=e,Ss=Qt=null,e=e.dependencies,e!==null&&e.firstContext!==null&&(e.lanes&t&&(pe=!0),e.firstContext=null)}function _e(e){var t=e._currentValue;if(Ss!==e)if(e={context:e,memoizedValue:t,next:null},Qt===null){if(el===null)throw Error(x(308));Qt=e,el.dependencies={lanes:0,firstContext:e}}else Qt=Qt.next=e;return t}var jt=null;function Ns(e){jt===null?jt=[e]:jt.push(e)}function Ca(e,t,n,r){var l=t.interleaved;return l===null?(n.next=n,Ns(t)):(n.next=l.next,l.next=n),t.interleaved=n,Xe(e,r)}function Xe(e,t){e.lanes|=t;var n=e.alternate;for(n!==null&&(n.lanes|=t),n=e,e=e.return;e!==null;)e.childLanes|=t,n=e.alternate,n!==null&&(n.childLanes|=t),n=e,e=e.return;return n.tag===3?n.stateNode:null}var et=!1;function Es(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,interleaved:null,lanes:0},effects:null}}function Na(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,effects:e.effects})}function Ke(e,t){return{eventTime:e,lane:t,tag:0,payload:null,callback:null,next:null}}function at(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,I&2){var l=r.pending;return l===null?t.next=t:(t.next=l.next,l.next=t),r.pending=t,Xe(e,n)}return l=r.interleaved,l===null?(t.next=t,Ns(r)):(t.next=l.next,l.next=t),r.interleaved=t,Xe(e,n)}function Rr(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,(n&4194240)!==0)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ds(e,n)}}function Fo(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var l=null,i=null;if(n=n.firstBaseUpdate,n!==null){do{var s={eventTime:n.eventTime,lane:n.lane,tag:n.tag,payload:n.payload,callback:n.callback,next:null};i===null?l=i=s:i=i.next=s,n=n.next}while(n!==null);i===null?l=i=t:i=i.next=t}else l=i=t;n={baseState:r.baseState,firstBaseUpdate:l,lastBaseUpdate:i,shared:r.shared,effects:r.effects},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}function tl(e,t,n,r){var l=e.updateQueue;et=!1;var i=l.firstBaseUpdate,s=l.lastBaseUpdate,u=l.shared.pending;if(u!==null){l.shared.pending=null;var a=u,c=a.next;a.next=null,s===null?i=c:s.next=c,s=a;var m=e.alternate;m!==null&&(m=m.updateQueue,u=m.lastBaseUpdate,u!==s&&(u===null?m.firstBaseUpdate=c:u.next=c,m.lastBaseUpdate=a))}if(i!==null){var v=l.baseState;s=0,m=c=a=null,u=i;do{var h=u.lane,y=u.eventTime;if((r&h)===h){m!==null&&(m=m.next={eventTime:y,lane:0,tag:u.tag,payload:u.payload,callback:u.callback,next:null});e:{var k=e,w=u;switch(h=t,y=n,w.tag){case 1:if(k=w.payload,typeof k=="function"){v=k.call(y,v,h);break e}v=k;break e;case 3:k.flags=k.flags&-65537|128;case 0:if(k=w.payload,h=typeof k=="function"?k.call(y,v,h):k,h==null)break e;v=V({},v,h);break e;case 2:et=!0}}u.callback!==null&&u.lane!==0&&(e.flags|=64,h=l.effects,h===null?l.effects=[u]:h.push(u))}else y={eventTime:y,lane:h,tag:u.tag,payload:u.payload,callback:u.callback,next:null},m===null?(c=m=y,a=v):m=m.next=y,s|=h;if(u=u.next,u===null){if(u=l.shared.pending,u===null)break;h=u,u=h.next,h.next=null,l.lastBaseUpdate=h,l.shared.pending=null}}while(!0);if(m===null&&(a=v),l.baseState=a,l.firstBaseUpdate=c,l.lastBaseUpdate=m,t=l.shared.interleaved,t!==null){l=t;do s|=l.lane,l=l.next;while(l!==t)}else i===null&&(l.shared.lanes=0);zt|=s,e.lanes=s,e.memoizedState=v}}function Do(e,t,n){if(e=t.effects,t.effects=null,e!==null)for(t=0;tn?n:4,e(!0);var r=Jl.transition;Jl.transition={};try{e(!1),t()}finally{F=n,Jl.transition=r}}function Va(){return Pe().memoizedState}function Sf(e,t,n){var r=dt(e);if(n={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null},Wa(e))Ha(t,n);else if(n=Ca(e,t,n,r),n!==null){var l=ae();Fe(n,e,r,l),Qa(n,t,r)}}function jf(e,t,n){var r=dt(e),l={lane:r,action:n,hasEagerState:!1,eagerState:null,next:null};if(Wa(e))Ha(t,l);else{var i=e.alternate;if(e.lanes===0&&(i===null||i.lanes===0)&&(i=t.lastRenderedReducer,i!==null))try{var s=t.lastRenderedState,u=i(s,n);if(l.hasEagerState=!0,l.eagerState=u,De(u,s)){var a=t.interleaved;a===null?(l.next=l,Ns(t)):(l.next=a.next,a.next=l),t.interleaved=l;return}}catch{}finally{}n=Ca(e,t,l,r),n!==null&&(l=ae(),Fe(n,e,r,l),Qa(n,t,r))}}function Wa(e){var t=e.alternate;return e===B||t!==null&&t===B}function Ha(e,t){Rn=rl=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function Qa(e,t,n){if(n&4194240){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,ds(e,n)}}var ll={readContext:_e,useCallback:le,useContext:le,useEffect:le,useImperativeHandle:le,useInsertionEffect:le,useLayoutEffect:le,useMemo:le,useReducer:le,useRef:le,useState:le,useDebugValue:le,useDeferredValue:le,useTransition:le,useMutableSource:le,useSyncExternalStore:le,useId:le,unstable_isNewReconciler:!1},Cf={readContext:_e,useCallback:function(e,t){return $e().memoizedState=[e,t===void 0?null:t],e},useContext:_e,useEffect:$o,useImperativeHandle:function(e,t,n){return n=n!=null?n.concat([e]):null,Ir(4194308,4,Ma.bind(null,t,e),n)},useLayoutEffect:function(e,t){return Ir(4194308,4,e,t)},useInsertionEffect:function(e,t){return Ir(4,2,e,t)},useMemo:function(e,t){var n=$e();return t=t===void 0?null:t,e=e(),n.memoizedState=[e,t],e},useReducer:function(e,t,n){var r=$e();return t=n!==void 0?n(t):t,r.memoizedState=r.baseState=t,e={pending:null,interleaved:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:t},r.queue=e,e=e.dispatch=Sf.bind(null,B,e),[r.memoizedState,e]},useRef:function(e){var t=$e();return e={current:e},t.memoizedState=e},useState:Mo,useDebugValue:Is,useDeferredValue:function(e){return $e().memoizedState=e},useTransition:function(){var e=Mo(!1),t=e[0];return e=xf.bind(null,e[1]),$e().memoizedState=e,[t,e]},useMutableSource:function(){},useSyncExternalStore:function(e,t,n){var r=B,l=$e();if(U){if(n===void 0)throw Error(x(407));n=n()}else{if(n=t(),ee===null)throw Error(x(349));Tt&30||Ta(r,t,n)}l.memoizedState=n;var i={value:n,getSnapshot:t};return l.queue=i,$o(La.bind(null,r,i,e),[e]),r.flags|=2048,Jn(9,za.bind(null,r,i,n,t),void 0,null),n},useId:function(){var e=$e(),t=ee.identifierPrefix;if(U){var n=Qe,r=He;n=(r&~(1<<32-Ie(r)-1)).toString(32)+n,t=":"+t+"R"+n,n=Xn++,0<\/script>",e=e.removeChild(e.firstChild)):typeof r.is=="string"?e=s.createElement(n,{is:r.is}):(e=s.createElement(n),n==="select"&&(s=e,r.multiple?s.multiple=!0:r.size&&(s.size=r.size))):e=s.createElementNS(e,n),e[Ue]=t,e[Kn]=r,tc(e,t,!1,!1),t.stateNode=e;e:{switch(s=vi(n,r),n){case"dialog":M("cancel",e),M("close",e),l=r;break;case"iframe":case"object":case"embed":M("load",e),l=r;break;case"video":case"audio":for(l=0;lon&&(t.flags|=128,r=!0,kn(i,!1),t.lanes=4194304)}else{if(!r)if(e=nl(s),e!==null){if(t.flags|=128,r=!0,n=e.updateQueue,n!==null&&(t.updateQueue=n,t.flags|=4),kn(i,!0),i.tail===null&&i.tailMode==="hidden"&&!s.alternate&&!U)return ie(t),null}else 2*K()-i.renderingStartTime>on&&n!==1073741824&&(t.flags|=128,r=!0,kn(i,!1),t.lanes=4194304);i.isBackwards?(s.sibling=t.child,t.child=s):(n=i.last,n!==null?n.sibling=s:t.child=s,i.last=s)}return i.tail!==null?(t=i.tail,i.rendering=t,i.tail=t.sibling,i.renderingStartTime=K(),t.sibling=null,n=A.current,D(A,r?n&1|2:n&1),t):(ie(t),null);case 22:case 23:return As(),r=t.memoizedState!==null,e!==null&&e.memoizedState!==null!==r&&(t.flags|=8192),r&&t.mode&1?ye&1073741824&&(ie(t),t.subtreeFlags&6&&(t.flags|=8192)):ie(t),null;case 24:return null;case 25:return null}throw Error(x(156,t.tag))}function Rf(e,t){switch(ks(t),t.tag){case 1:return me(t.type)&&Xr(),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return ln(),$(he),$(oe),Ts(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 5:return Ps(t),null;case 13:if($(A),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(x(340));nn()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return $(A),null;case 4:return ln(),null;case 10:return Cs(t.type._context),null;case 22:case 23:return As(),null;case 24:return null;default:return null}}var Cr=!1,se=!1,Of=typeof WeakSet=="function"?WeakSet:Set,j=null;function Kt(e,t){var n=e.ref;if(n!==null)if(typeof n=="function")try{n(null)}catch(r){W(e,t,r)}else n.current=null}function Wi(e,t,n){try{n()}catch(r){W(e,t,r)}}var Xo=!1;function If(e,t){if(Ei=Qr,e=ua(),gs(e)){if("selectionStart"in e)var n={start:e.selectionStart,end:e.selectionEnd};else e:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var l=r.anchorOffset,i=r.focusNode;r=r.focusOffset;try{n.nodeType,i.nodeType}catch{n=null;break e}var s=0,u=-1,a=-1,c=0,m=0,v=e,h=null;t:for(;;){for(var y;v!==n||l!==0&&v.nodeType!==3||(u=s+l),v!==i||r!==0&&v.nodeType!==3||(a=s+r),v.nodeType===3&&(s+=v.nodeValue.length),(y=v.firstChild)!==null;)h=v,v=y;for(;;){if(v===e)break t;if(h===n&&++c===l&&(u=s),h===i&&++m===r&&(a=s),(y=v.nextSibling)!==null)break;v=h,h=v.parentNode}v=y}n=u===-1||a===-1?null:{start:u,end:a}}else n=null}n=n||{start:0,end:0}}else n=null;for(_i={focusedElem:e,selectionRange:n},Qr=!1,j=t;j!==null;)if(t=j,e=t.child,(t.subtreeFlags&1028)!==0&&e!==null)e.return=t,j=e;else for(;j!==null;){t=j;try{var k=t.alternate;if(t.flags&1024)switch(t.tag){case 0:case 11:case 15:break;case 1:if(k!==null){var w=k.memoizedProps,T=k.memoizedState,p=t.stateNode,d=p.getSnapshotBeforeUpdate(t.elementType===t.type?w:Le(t.type,w),T);p.__reactInternalSnapshotBeforeUpdate=d}break;case 3:var f=t.stateNode.containerInfo;f.nodeType===1?f.textContent="":f.nodeType===9&&f.documentElement&&f.removeChild(f.documentElement);break;case 5:case 6:case 4:case 17:break;default:throw Error(x(163))}}catch(g){W(t,t.return,g)}if(e=t.sibling,e!==null){e.return=t.return,j=e;break}j=t.return}return k=Xo,Xo=!1,k}function On(e,t,n){var r=t.updateQueue;if(r=r!==null?r.lastEffect:null,r!==null){var l=r=r.next;do{if((l.tag&e)===e){var i=l.destroy;l.destroy=void 0,i!==void 0&&Wi(t,n,i)}l=l.next}while(l!==r)}}function wl(e,t){if(t=t.updateQueue,t=t!==null?t.lastEffect:null,t!==null){var n=t=t.next;do{if((n.tag&e)===e){var r=n.create;n.destroy=r()}n=n.next}while(n!==t)}}function Hi(e){var t=e.ref;if(t!==null){var n=e.stateNode;switch(e.tag){case 5:e=n;break;default:e=n}typeof t=="function"?t(e):t.current=e}}function lc(e){var t=e.alternate;t!==null&&(e.alternate=null,lc(t)),e.child=null,e.deletions=null,e.sibling=null,e.tag===5&&(t=e.stateNode,t!==null&&(delete t[Ue],delete t[Kn],delete t[zi],delete t[vf],delete t[yf])),e.stateNode=null,e.return=null,e.dependencies=null,e.memoizedProps=null,e.memoizedState=null,e.pendingProps=null,e.stateNode=null,e.updateQueue=null}function ic(e){return e.tag===5||e.tag===3||e.tag===4}function Zo(e){e:for(;;){for(;e.sibling===null;){if(e.return===null||ic(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.flags&2||e.child===null||e.tag===4)continue e;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Qi(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.nodeType===8?n.parentNode.insertBefore(e,t):n.insertBefore(e,t):(n.nodeType===8?(t=n.parentNode,t.insertBefore(e,n)):(t=n,t.appendChild(e)),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=Yr));else if(r!==4&&(e=e.child,e!==null))for(Qi(e,t,n),e=e.sibling;e!==null;)Qi(e,t,n),e=e.sibling}function Ki(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(e=e.child,e!==null))for(Ki(e,t,n),e=e.sibling;e!==null;)Ki(e,t,n),e=e.sibling}var te=null,Re=!1;function qe(e,t,n){for(n=n.child;n!==null;)sc(e,t,n),n=n.sibling}function sc(e,t,n){if(Ae&&typeof Ae.onCommitFiberUnmount=="function")try{Ae.onCommitFiberUnmount(dl,n)}catch{}switch(n.tag){case 5:se||Kt(n,t);case 6:var r=te,l=Re;te=null,qe(e,t,n),te=r,Re=l,te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?e.parentNode.removeChild(n):e.removeChild(n)):te.removeChild(n.stateNode));break;case 18:te!==null&&(Re?(e=te,n=n.stateNode,e.nodeType===8?Yl(e.parentNode,n):e.nodeType===1&&Yl(e,n),Bn(e)):Yl(te,n.stateNode));break;case 4:r=te,l=Re,te=n.stateNode.containerInfo,Re=!0,qe(e,t,n),te=r,Re=l;break;case 0:case 11:case 14:case 15:if(!se&&(r=n.updateQueue,r!==null&&(r=r.lastEffect,r!==null))){l=r=r.next;do{var i=l,s=i.destroy;i=i.tag,s!==void 0&&(i&2||i&4)&&Wi(n,t,s),l=l.next}while(l!==r)}qe(e,t,n);break;case 1:if(!se&&(Kt(n,t),r=n.stateNode,typeof r.componentWillUnmount=="function"))try{r.props=n.memoizedProps,r.state=n.memoizedState,r.componentWillUnmount()}catch(u){W(n,t,u)}qe(e,t,n);break;case 21:qe(e,t,n);break;case 22:n.mode&1?(se=(r=se)||n.memoizedState!==null,qe(e,t,n),se=r):qe(e,t,n);break;default:qe(e,t,n)}}function Jo(e){var t=e.updateQueue;if(t!==null){e.updateQueue=null;var n=e.stateNode;n===null&&(n=e.stateNode=new Of),t.forEach(function(r){var l=Wf.bind(null,e,r);n.has(r)||(n.add(r),r.then(l,l))})}}function ze(e,t){var n=t.deletions;if(n!==null)for(var r=0;rl&&(l=s),r&=~i}if(r=l,r=K()-r,r=(120>r?120:480>r?480:1080>r?1080:1920>r?1920:3e3>r?3e3:4320>r?4320:1960*Df(r/1960))-r,10e?16:e,lt===null)var r=!1;else{if(e=lt,lt=null,ol=0,I&6)throw Error(x(331));var l=I;for(I|=4,j=e.current;j!==null;){var i=j,s=i.child;if(j.flags&16){var u=i.deletions;if(u!==null){for(var a=0;aK()-$s?Nt(e,0):Ms|=n),ve(e,t)}function hc(e,t){t===0&&(e.mode&1?(t=mr,mr<<=1,!(mr&130023424)&&(mr=4194304)):t=1);var n=ae();e=Xe(e,t),e!==null&&(nr(e,t,n),ve(e,n))}function Vf(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),hc(e,n)}function Wf(e,t){var n=0;switch(e.tag){case 13:var r=e.stateNode,l=e.memoizedState;l!==null&&(n=l.retryLane);break;case 19:r=e.stateNode;break;default:throw Error(x(314))}r!==null&&r.delete(t),hc(e,n)}var mc;mc=function(e,t,n){if(e!==null)if(e.memoizedProps!==t.pendingProps||he.current)pe=!0;else{if(!(e.lanes&n)&&!(t.flags&128))return pe=!1,zf(e,t,n);pe=!!(e.flags&131072)}else pe=!1,U&&t.flags&1048576&&wa(t,qr,t.index);switch(t.lanes=0,t.tag){case 2:var r=t.type;Fr(e,t),e=t.pendingProps;var l=tn(t,oe.current);qt(t,n),l=Ls(null,t,r,e,l,n);var i=Rs();return t.flags|=1,typeof l=="object"&&l!==null&&typeof l.render=="function"&&l.$$typeof===void 0?(t.tag=1,t.memoizedState=null,t.updateQueue=null,me(r)?(i=!0,Zr(t)):i=!1,t.memoizedState=l.state!==null&&l.state!==void 0?l.state:null,Es(t),l.updater=gl,t.stateNode=l,l._reactInternals=t,Di(t,r,e,n),t=Ui(null,t,r,!0,i,n)):(t.tag=0,U&&i&&ws(t),ue(null,t,l,n),t=t.child),t;case 16:r=t.elementType;e:{switch(Fr(e,t),e=t.pendingProps,l=r._init,r=l(r._payload),t.type=r,l=t.tag=Qf(r),e=Le(r,e),l){case 0:t=$i(null,t,r,e,n);break e;case 1:t=Ko(null,t,r,e,n);break e;case 11:t=Ho(null,t,r,e,n);break e;case 14:t=Qo(null,t,r,Le(r.type,e),n);break e}throw Error(x(306,r,""))}return t;case 0:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),$i(e,t,r,l,n);case 1:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Ko(e,t,r,l,n);case 3:e:{if(qa(t),e===null)throw Error(x(387));r=t.pendingProps,i=t.memoizedState,l=i.element,Na(e,t),tl(t,r,null,n);var s=t.memoizedState;if(r=s.element,i.isDehydrated)if(i={element:r,isDehydrated:!1,cache:s.cache,pendingSuspenseBoundaries:s.pendingSuspenseBoundaries,transitions:s.transitions},t.updateQueue.baseState=i,t.memoizedState=i,t.flags&256){l=sn(Error(x(423)),t),t=Go(e,t,r,n,l);break e}else if(r!==l){l=sn(Error(x(424)),t),t=Go(e,t,r,n,l);break e}else for(ge=ut(t.stateNode.containerInfo.firstChild),we=t,U=!0,Oe=null,n=ja(t,null,r,n),t.child=n;n;)n.flags=n.flags&-3|4096,n=n.sibling;else{if(nn(),r===l){t=Ze(e,t,n);break e}ue(e,t,r,n)}t=t.child}return t;case 5:return Ea(t),e===null&&Oi(t),r=t.type,l=t.pendingProps,i=e!==null?e.memoizedProps:null,s=l.children,Pi(r,l)?s=null:i!==null&&Pi(r,i)&&(t.flags|=32),Ja(e,t),ue(e,t,s,n),t.child;case 6:return e===null&&Oi(t),null;case 13:return ba(e,t,n);case 4:return _s(t,t.stateNode.containerInfo),r=t.pendingProps,e===null?t.child=rn(t,null,r,n):ue(e,t,r,n),t.child;case 11:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Ho(e,t,r,l,n);case 7:return ue(e,t,t.pendingProps,n),t.child;case 8:return ue(e,t,t.pendingProps.children,n),t.child;case 12:return ue(e,t,t.pendingProps.children,n),t.child;case 10:e:{if(r=t.type._context,l=t.pendingProps,i=t.memoizedProps,s=l.value,D(br,r._currentValue),r._currentValue=s,i!==null)if(De(i.value,s)){if(i.children===l.children&&!he.current){t=Ze(e,t,n);break e}}else for(i=t.child,i!==null&&(i.return=t);i!==null;){var u=i.dependencies;if(u!==null){s=i.child;for(var a=u.firstContext;a!==null;){if(a.context===r){if(i.tag===1){a=Ke(-1,n&-n),a.tag=2;var c=i.updateQueue;if(c!==null){c=c.shared;var m=c.pending;m===null?a.next=a:(a.next=m.next,m.next=a),c.pending=a}}i.lanes|=n,a=i.alternate,a!==null&&(a.lanes|=n),Ii(i.return,n,t),u.lanes|=n;break}a=a.next}}else if(i.tag===10)s=i.type===t.type?null:i.child;else if(i.tag===18){if(s=i.return,s===null)throw Error(x(341));s.lanes|=n,u=s.alternate,u!==null&&(u.lanes|=n),Ii(s,n,t),s=i.sibling}else s=i.child;if(s!==null)s.return=i;else for(s=i;s!==null;){if(s===t){s=null;break}if(i=s.sibling,i!==null){i.return=s.return,s=i;break}s=s.return}i=s}ue(e,t,l.children,n),t=t.child}return t;case 9:return l=t.type,r=t.pendingProps.children,qt(t,n),l=_e(l),r=r(l),t.flags|=1,ue(e,t,r,n),t.child;case 14:return r=t.type,l=Le(r,t.pendingProps),l=Le(r.type,l),Qo(e,t,r,l,n);case 15:return Xa(e,t,t.type,t.pendingProps,n);case 17:return r=t.type,l=t.pendingProps,l=t.elementType===r?l:Le(r,l),Fr(e,t),t.tag=1,me(r)?(e=!0,Zr(t)):e=!1,qt(t,n),Ka(t,r,l),Di(t,r,l,n),Ui(null,t,r,!0,e,n);case 19:return ec(e,t,n);case 22:return Za(e,t,n)}throw Error(x(156,t.tag))};function vc(e,t){return Wu(e,t)}function Hf(e,t,n,r){this.tag=e,this.key=n,this.sibling=this.child=this.return=this.stateNode=this.type=this.elementType=null,this.index=0,this.ref=null,this.pendingProps=t,this.dependencies=this.memoizedState=this.updateQueue=this.memoizedProps=null,this.mode=r,this.subtreeFlags=this.flags=0,this.deletions=null,this.childLanes=this.lanes=0,this.alternate=null}function Ne(e,t,n,r){return new Hf(e,t,n,r)}function Vs(e){return e=e.prototype,!(!e||!e.isReactComponent)}function Qf(e){if(typeof e=="function")return Vs(e)?1:0;if(e!=null){if(e=e.$$typeof,e===os)return 11;if(e===us)return 14}return 2}function ft(e,t){var n=e.alternate;return n===null?(n=Ne(e.tag,t,e.key,e.mode),n.elementType=e.elementType,n.type=e.type,n.stateNode=e.stateNode,n.alternate=e,e.alternate=n):(n.pendingProps=t,n.type=e.type,n.flags=0,n.subtreeFlags=0,n.deletions=null),n.flags=e.flags&14680064,n.childLanes=e.childLanes,n.lanes=e.lanes,n.child=e.child,n.memoizedProps=e.memoizedProps,n.memoizedState=e.memoizedState,n.updateQueue=e.updateQueue,t=e.dependencies,n.dependencies=t===null?null:{lanes:t.lanes,firstContext:t.firstContext},n.sibling=e.sibling,n.index=e.index,n.ref=e.ref,n}function $r(e,t,n,r,l,i){var s=2;if(r=e,typeof e=="function")Vs(e)&&(s=1);else if(typeof e=="string")s=5;else e:switch(e){case Mt:return Et(n.children,l,i,t);case ss:s=8,l|=8;break;case si:return e=Ne(12,n,t,l|2),e.elementType=si,e.lanes=i,e;case oi:return e=Ne(13,n,t,l),e.elementType=oi,e.lanes=i,e;case ui:return e=Ne(19,n,t,l),e.elementType=ui,e.lanes=i,e;case Eu:return xl(n,l,i,t);default:if(typeof e=="object"&&e!==null)switch(e.$$typeof){case Cu:s=10;break e;case Nu:s=9;break e;case os:s=11;break e;case us:s=14;break e;case be:s=16,r=null;break e}throw Error(x(130,e==null?e:typeof e,""))}return t=Ne(s,n,t,l),t.elementType=e,t.type=r,t.lanes=i,t}function Et(e,t,n,r){return e=Ne(7,e,r,t),e.lanes=n,e}function xl(e,t,n,r){return e=Ne(22,e,r,t),e.elementType=Eu,e.lanes=n,e.stateNode={isHidden:!1},e}function ni(e,t,n){return e=Ne(6,e,null,t),e.lanes=n,e}function ri(e,t,n){return t=Ne(4,e.children!==null?e.children:[],e.key,t),t.lanes=n,t.stateNode={containerInfo:e.containerInfo,pendingChildren:null,implementation:e.implementation},t}function Kf(e,t,n,r,l){this.tag=t,this.containerInfo=e,this.finishedWork=this.pingCache=this.current=this.pendingChildren=null,this.timeoutHandle=-1,this.callbackNode=this.pendingContext=this.context=null,this.callbackPriority=0,this.eventTimes=Ml(0),this.expirationTimes=Ml(-1),this.entangledLanes=this.finishedLanes=this.mutableReadLanes=this.expiredLanes=this.pingedLanes=this.suspendedLanes=this.pendingLanes=0,this.entanglements=Ml(0),this.identifierPrefix=r,this.onRecoverableError=l,this.mutableSourceEagerHydrationData=null}function Ws(e,t,n,r,l,i,s,u,a){return e=new Kf(e,t,n,u,a),t===1?(t=1,i===!0&&(t|=8)):t=0,i=Ne(3,null,null,t),e.current=i,i.stateNode=e,i.memoizedState={element:r,isDehydrated:n,cache:null,transitions:null,pendingSuspenseBoundaries:null},Es(i),e}function Gf(e,t,n){var r=3"u"||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!="function"))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(kc)}catch(e){console.error(e)}}kc(),ku.exports=xe;var qf=ku.exports,xc,iu=qf;xc=iu.createRoot,iu.hydrateRoot;const Sc="bv.access",Gs="bv.refresh";function Ji(e){try{return localStorage.getItem(e)||""}catch{return""}}function su(e,t){try{t?localStorage.setItem(e,t):localStorage.removeItem(e)}catch{}}function sr(){return{access:Ji(Sc),refresh:Ji(Gs)}}function Ys(e,t){su(Sc,e),su(Gs,t)}function or(){Ys("","")}function bf(){return!!Ji(Gs)}class bn extends Error{constructor(t,n,r){super(r),this.status=t,this.code=n}}let Sn=null;async function El(){return Sn||(Sn=(async()=>{const{refresh:e}=sr();if(!e)throw new bn(401,"unauthorized","Signed out.");const t=await fetch("/api/auth/refresh",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({refresh_token:e})});if(!t.ok)throw or(),new bn(t.status,"unauthorized","Your session has ended. Sign in again.");const n=await t.json();return Ys(n.access_token,n.refresh_token),n})().finally(()=>{Sn=null}),Sn)}async function G(e,t,n,r=!0){const{access:l}=sr(),i={};l&&(i.Authorization="Bearer "+l),n!==void 0&&(i["Content-Type"]="application/json");const s=await fetch(t,{method:e,headers:i,body:n===void 0?void 0:JSON.stringify(n)});if(s.status===204)return null;const u=await s.text();let a=null;try{a=u?JSON.parse(u):null}catch{}if(s.ok)return a;const c=(a==null?void 0:a.error)||"";if(c==="token_expired"&&r)return await El(),G(e,t,n,!1);throw s.status===401&&or(),new bn(s.status,c,(a==null?void 0:a.message)||`Something went wrong (${s.status}).`)}async function jc(e,t=!0){const{access:n}=sr(),r=await fetch(e,{headers:n?{Authorization:"Bearer "+n}:{}});if(r.ok)return URL.createObjectURL(await r.blob());let l=null;try{l=await r.json()}catch{}const i=(l==null?void 0:l.error)||"";if(i==="token_expired"&&t)return await El(),jc(e,!1);throw r.status===401&&or(),new bn(r.status,i,(l==null?void 0:l.message)||`That picture could not be loaded (${r.status}).`)}const Ft=e=>{const t=new URLSearchParams;for(const[r,l]of Object.entries(e||{}))l!=null&&l!==""&&t.set(r,l);const n=t.toString();return n?"?"+n:""},J={async login(e,t){const n=await fetch("/api/auth/login",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({email:e,password:t})}),r=await n.json().catch(()=>null);if(!n.ok)throw new bn(n.status,(r==null?void 0:r.error)||"",(r==null?void 0:r.message)||"Could not sign in.");return Ys(r.access_token,r.refresh_token),r.user},async logout(){try{await G("POST","/api/auth/logout")}catch{}or()},me:()=>G("GET","/api/auth/me"),sites:()=>G("GET","/api/sites"),arrivals:e=>G("GET","/api/visits"+Ft(e)),visitors:(e,t=50)=>G("GET","/api/visitors"+Ft({q:e,limit:t})),visitorHistory:(e,t=50)=>G("GET",`/api/visitors/${encodeURIComponent(e)}/history`+Ft({limit:t})),saveProfile:(e,t)=>G("PUT",`/api/visitors/${encodeURIComponent(e)}/profile`,t),footfall:e=>G("GET","/api/reports/footfall"+Ft(e)),conversion:e=>G("GET","/api/reports/conversion"+Ft(e)),cameras:()=>G("GET","/api/cameras"),cameraSnapshot:e=>jc(e),createCamera:(e,t)=>G("POST",`/api/sites/${encodeURIComponent(e)}/cameras`,t),updateCamera:(e,t)=>G("PATCH",`/api/cameras/${encodeURIComponent(e)}`,t),deleteCamera:e=>G("DELETE",`/api/cameras/${encodeURIComponent(e)}`),checkCamera:(e,t,n)=>G("POST",`/api/cameras/${encodeURIComponent(e)}/check`,{kind:t,...n?{seconds:n}:{}}),siteCheck:e=>G("GET",`/api/sites/${encodeURIComponent(e)}/check`),enrolmentCode:(e,t)=>G("POST",`/api/sites/${encodeURIComponent(e)}/enrolment-code`,t||{}),ask:e=>G("POST","/api/assistant",{history:e}),clients:()=>G("GET","/api/admin/clients"),createClient:e=>G("POST","/api/admin/clients",e)};function ep({cursor:e,siteId:t,onPage:n,onError:r,signal:l}){let i=!1,s=e||"";return(async()=>{for(;!i;){try{const{access:a}=sr(),c=await fetch("/api/visits/stream"+Ft({cursor:s,site_id:t}),{headers:{Authorization:"Bearer "+a,Accept:"text/event-stream"},signal:l});if(c.status===401){await El();continue}if(!c.ok||!c.body)throw new Error("stream unavailable");const m=c.body.getReader(),v=new TextDecoder;let h="";for(;!i;){const{value:y,done:k}=await m.read();if(k)break;h+=v.decode(y,{stream:!0});let w;for(;(w=h.indexOf(` - -`))!==-1;){const T=h.slice(0,w);h=h.slice(w+2);for(const p of T.split(` -`))if(p.startsWith("id: "))s=p.slice(4).trim();else if(p.startsWith("data: "))try{n(JSON.parse(p.slice(6)))}catch{}}}}catch(a){if(i||l!=null&&l.aborted)return;r==null||r(a)}if(i)return;await new Promise(a=>setTimeout(a,3e3))}})(),()=>{i=!0}}function tp({cameraId:e,img:t,onState:n,signal:r}){let l=!1;return(async()=>{for(;!l;){try{const{access:s}=sr(),u=await fetch(`/api/cameras/${e}/live`,{headers:{Authorization:"Bearer "+s,Accept:"text/event-stream"},signal:r});if(u.status===401){await El();continue}if(!u.ok||!u.body)throw new Error("live view unavailable");const a=u.body.getReader(),c=new TextDecoder;let m="";for(;!l;){const{value:v,done:h}=await a.read();if(h)break;m+=c.decode(v,{stream:!0});let y;for(;(y=m.indexOf(` - -`))!==-1;){const k=m.slice(0,y);m=m.slice(y+2);let w="message",T="";for(const p of k.split(` -`))p.startsWith("event: ")?w=p.slice(7).trim():p.startsWith("data: ")&&(T=p.slice(6));w==="frame"&&T?(t.current&&(t.current.src="data:image/jpeg;base64,"+T),n==null||n("live")):w==="waiting"&&(n==null||n("waiting"))}}}catch{if(l||r!=null&&r.aborted)return;n==null||n("reconnecting")}if(l)return;await new Promise(s=>setTimeout(s,1500))}})(),()=>{l=!0}}function np({onSignedIn:e}){const[t,n]=N.useState(""),[r,l]=N.useState(""),[i,s]=N.useState(""),[u,a]=N.useState(!1),c=async m=>{m.preventDefault(),a(!0),s("");try{await J.login(t.trim(),r),e(await J.me())}catch(v){s(v.message),a(!1)}};return o.jsx("div",{className:"signin",children:o.jsxs("form",{className:"card",onSubmit:c,children:[o.jsx("span",{className:"mark big","aria-hidden":"true"}),o.jsx("h1",{children:"Behavision"}),o.jsx("p",{className:"sub",children:"Sign in to your company account."}),o.jsxs("label",{children:["Email",o.jsx("input",{type:"email",value:t,autoComplete:"username",autoFocus:!0,required:!0,onChange:m=>n(m.target.value),placeholder:"you@company.com"})]}),o.jsxs("label",{children:["Password",o.jsx("input",{type:"password",value:r,autoComplete:"current-password",required:!0,onChange:m=>l(m.target.value)})]}),i&&o.jsx("p",{className:"error",role:"alert",children:i}),o.jsx("button",{className:"primary",disabled:u||!t||!r,children:u?"Signing in…":"Sign in"}),o.jsx("p",{className:"foot",children:"Accounts are created by Loyaly. Ask your account manager if you need one."})]})})}function er(e,t,n=[]){const[r,l]=N.useState(null),[i,s]=N.useState(null),[u,a]=N.useState(!0),c=N.useRef(!1),m=N.useRef(!0),v=N.useCallback(async()=>{if(!c.current){c.current=!0;try{const h=await e();if(!m.current)return;l(h),s(null)}catch(h){if(!m.current)return;s(h)}finally{c.current=!1,m.current&&a(!1)}}},n);return N.useEffect(()=>{if(m.current=!0,v(),!t)return()=>{m.current=!1};const h=setInterval(v,t);return()=>{m.current=!1,clearInterval(h)}},[v,t]),{data:r,error:i,loading:u,reload:v}}function rp(e){const[t,n]=N.useState(null);return N.useEffect(()=>{if(!e){n(null);return}let r=!0,l=null;return J.cameraSnapshot(e).then(i=>{if(!r){URL.revokeObjectURL(i);return}l=i,n(i)}).catch(()=>{r&&n(null)}),()=>{r=!1,l&&URL.revokeObjectURL(l)}},[e]),t}function Cc({url:e,alt:t}){const n=typeof e=="string"&&e.startsWith("/"),r=rp(n?e:null),l=n?r:e;return l?o.jsx("img",{src:l,alt:t,loading:"lazy"}):null}function lp({site:e,onClose:t}){const[n,r]=N.useState(null),[l,i]=N.useState(!1),[s,u]=N.useState(""),a=async()=>{i(!0),u(""),r(null);try{r(await J.siteCheck(e.site_id))}catch(c){u(c.message)}finally{i(!1)}};return o.jsx("div",{className:"overlay",onClick:t,children:o.jsxs("aside",{className:"drawer",onClick:c=>c.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsxs("div",{children:[o.jsxs("h2",{children:["Is ",e.name," working?"]}),o.jsx("p",{className:"sub",children:"Checks the whole chain, from the shop’s PC to head office."})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Close"})]}),o.jsxs("div",{className:"drawer-body",children:[!n&&!l&&o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"sub",children:"Nothing is changed and no one is disturbed — this reads what the shop has already reported."}),o.jsx("button",{className:"primary",onClick:a,children:"Run the check"})]}),l&&o.jsxs("div",{className:"waiting",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),o.jsx("b",{children:"Checking…"})]}),s&&o.jsx("p",{className:"error",role:"alert",children:s}),n&&o.jsxs(o.Fragment,{children:[o.jsx("div",{className:"banner "+(n.ok?"ok":"warn"),children:o.jsxs("div",{children:[o.jsx("b",{children:n.ok?"This shop is working.":"This shop needs attention."}),!n.ok&&o.jsx(o.Fragment,{children:" Work down the list — the first failure usually explains the rest."})]})}),o.jsx("ol",{className:"checklist",children:n.steps.map(c=>o.jsxs("li",{className:c.status,children:[o.jsx("span",{className:"mark","aria-hidden":"true",children:c.status==="pass"?"✓":c.status==="fail"?"✕":c.status==="warn"?"!":"–"}),o.jsxs("div",{children:[o.jsx("b",{children:c.name}),o.jsx("span",{className:"sub",children:c.detail}),c.advice&&o.jsx("span",{className:"advice-line",children:c.advice})]})]},c.name))}),o.jsx("button",{className:"ghost",onClick:a,children:"Check again"})]}),o.jsx(ip,{site:e})]})]})})}function ip({site:e}){const[t,n]=N.useState(null),[r,l]=N.useState(!1),[i,s]=N.useState(""),[u,a]=N.useState(""),c=async()=>{l(!0),s("");try{n(await J.enrolmentCode(e.site_id,{label:u.trim()}))}catch(m){s(m.message)}finally{l(!1)}};return o.jsxs("section",{className:"claim",children:[o.jsx("h3",{children:"Set up a shop PC"}),t?o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"code","aria-live":"polite",children:t.code}),o.jsxs("p",{className:"sub",children:["Copy this now — it cannot be shown again. It works once, and stops working ",op(t.expires_at),"."]}),o.jsxs("div",{className:"row",children:[o.jsx("button",{className:"ghost",onClick:()=>sp(t.code),children:"Copy"}),o.jsx("button",{className:"ghost",onClick:()=>n(null),children:"Done"})]})]}):o.jsxs(o.Fragment,{children:[o.jsx("p",{className:"sub",children:"Creates a one-time code to type into Behavision on the shop’s computer. Use one for a new shop, a replacement PC, or a reinstall."}),o.jsxs("label",{className:"field",children:[o.jsx("span",{children:"What is this PC? (optional)"}),o.jsx("input",{value:u,placeholder:"counter PC",onChange:m=>a(m.target.value)})]}),i&&o.jsx("p",{className:"error",role:"alert",children:i}),o.jsx("button",{className:"ghost",onClick:c,disabled:r,children:r?"Creating…":"Create an installation code"})]})]})}function sp(e){var t;try{(t=navigator.clipboard)==null||t.writeText(e)}catch{}}function op(e){const t=new Date(e).getTime();if(Number.isNaN(t))return"shortly";const n=Math.round((t-Date.now())/864e5);return n<=0?"today":n===1?"tomorrow":`in ${n} days`}function up(){const{data:e,error:t,loading:n}=er(()=>J.sites(),2e4,[]),{data:r}=er(()=>J.cameras(),6e4,[]),[l,i]=N.useState(null),s=e||[];if(n&&!e)return o.jsx(_l,{});if(t)return o.jsx(Pl,{error:t});if(!s.length)return o.jsx(hp,{});const u=s.map(h=>{const y=(r||[]).filter(k=>k.site_id===h.site_id);return{site:h,cams:y,verdict:ap(h,y)}}),a=h=>u.filter(y=>y.verdict.tone===h).length,c=a("bad"),m=a("warn"),v=a("idle");return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Shops"}),o.jsxs("p",{className:"sub",children:[s.length," ",s.length===1?"shop":"shops",c>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"bad",children:[c," not working"]})]}),m>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"warn",children:[m," needing attention"]})]}),v>0&&o.jsxs(o.Fragment,{children:[" · ",v," not set up yet"]}),!c&&!m&&!v&&o.jsxs(o.Fragment,{children:[" · ",o.jsx("b",{className:"ok",children:"all working"})]})]})]}),o.jsx("div",{className:"grid sites",children:u.map(h=>o.jsx(dp,{site:h.site,cams:h.cams,verdict:h.verdict,onCheck:()=>i(h.site)},h.site.site_id))}),l&&o.jsx(lp,{site:l,onClose:()=>i(null)})]})}function ap(e,t){const n=e.fraction_below_gate,r=e.cameras_total||t.length;return e.online?e.dropped>0?{tone:"bad",mark:"✕",words:`${e.dropped} visits lost and unrecoverable`}:r===0?{tone:"idle",mark:"+",headline:"Not set up",words:"No cameras set up yet"}:e.cameras_up===0?{tone:"bad",mark:"✕",words:"No cameras connected"}:e.cameras_up.5?{tone:"bad",mark:"✕",words:`${qi(n)} of faces too poor to recognise`}:n>.2?{tone:"warn",mark:"!",words:`${qi(n)} of faces too poor to recognise`}:e.queued>0?{tone:"warn",mark:"!",words:`${e.queued} visits waiting to upload`}:{tone:"ok",mark:"✓",words:`Working — ${r} ${r===1?"camera":"cameras"} connected`}:{tone:"bad",mark:"✕",headline:"Offline",words:`Offline — last heard from ${un(e.last_heartbeat_at)}`}}const cp={ok:"Working",warn:"Needs attention",bad:"Not working",idle:"Not set up"};function dp({site:e,cams:t,verdict:n,onCheck:r}){const l=e.fraction_below_gate,i=n.tone,s=n.headline||cp[i],u=fp(t),a=e.cameras_total||t.length;return o.jsxs("article",{className:"card site state-"+i,onClick:r,role:"button",tabIndex:0,onKeyDown:c=>c.key==="Enter"&&r(),children:[o.jsxs("div",{className:"shot",children:[u.url?o.jsx(Cc,{url:u.url,alt:`View inside ${e.name}`}):o.jsxs("div",{className:"noshot",children:[o.jsx(pp,{}),u.reason&&o.jsx("span",{children:u.reason})]}),o.jsxs("div",{className:"shot-over",children:[o.jsxs("div",{className:"shot-name",children:[o.jsx("b",{children:e.name}),o.jsx("span",{children:a>0?`${a} ${a===1?"camera":"cameras"}`:"No cameras yet"})]}),o.jsxs("span",{className:"status "+i,children:[o.jsx("i",{"aria-hidden":"true"}),s]})]}),u.at&&o.jsx("span",{className:"shot-age",children:un(u.at)})]}),o.jsxs("div",{className:"metrics",children:[o.jsx(li,{label:"Cameras",value:a?`${e.cameras_up}/${a}`:"—",tone:a?e.cameras_up0?qi(1-l):"—",tone:l?l>.5?"bad":l>.2?"warn":"ok":"idle"}),o.jsx(li,{label:"Last seen",value:un(e.last_heartbeat_at),tone:e.online?"ok":"bad"})]}),o.jsxs("div",{className:"verdict "+n.tone,children:[o.jsx("span",{className:"mark","aria-hidden":"true",children:n.mark}),o.jsx("span",{className:"words",children:n.words}),o.jsx("span",{className:"go","aria-hidden":"true",children:"→"})]})]})}function li({label:e,value:t,tone:n}){return o.jsxs("div",{className:"metric",children:[o.jsx("b",{className:n,children:t}),o.jsx("span",{children:e})]})}function fp(e){var r;if(!e.length)return{};let t=null;for(const l of e)!((r=l.snapshot)!=null&&r.available)||!l.snapshot.url||(!t||(l.snapshot_at||"")>(t.snapshot_at||""))&&(t=l);return t?{url:t.snapshot.url,at:t.snapshot_at}:{reason:e.map(l=>{var i;return(i=l.snapshot)==null?void 0:i.reason}).find(Boolean)||"No picture from this shop yet."}}function pp(){return o.jsxs("svg",{className:"shopmark",viewBox:"0 0 40 32","aria-hidden":"true",children:[o.jsx("path",{d:"M4 12h32v18H4z"}),o.jsx("path",{d:"M2 12l4-8h28l4 8"}),o.jsx("path",{d:"M15 30v-9h10v9"})]})}function qi(e){return`${Math.round(e*100)}%`}function un(e){if(!e)return"never";const t=new Date(e).getTime();if(Number.isNaN(t))return"—";const n=Math.max(0,(Date.now()-t)/1e3);if(n<90)return"just now";const r=Math.round(n/60);if(r<60)return`${r} min ago`;const l=Math.round(r/60);return l<48?`${l} h ago`:`${Math.round(l/24)} days ago`}function _l(){return o.jsxs("div",{className:"state",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),"Loading…"]})}function Pl({error:e}){return o.jsx("div",{className:"state",children:o.jsx("p",{className:"error",role:"alert",children:e.message})})}function hp(){return o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"No shops yet"}),o.jsx("p",{className:"sub",children:"A shop appears here once its PC has been claimed with an enrolment code."})]})}const ou=60;function mp(){const[e,t]=N.useState([]),[n,r]=N.useState("connecting"),[l,i]=N.useState(null),s=N.useRef(new Set);return N.useEffect(()=>{const u=new AbortController;let a=()=>{};return(async()=>{try{const c=await J.arrivals({limit:30}),m=c.arrivals||[];m.forEach(v=>s.current.add(v.visit_id)),t(m.slice().reverse()),r("live"),a=ep({cursor:c.cursor,signal:u.signal,onPage:v=>{const h=(v.arrivals||[]).filter(y=>!s.current.has(y.visit_id));h.length&&(h.forEach(y=>s.current.add(y.visit_id)),r("live"),t(y=>[...h.reverse(),...y].slice(0,ou)))},onError:()=>r("reconnecting")})}catch(c){i(c)}})(),()=>{u.abort(),a()}},[]),l?o.jsx(Pl,{error:l}):n==="connecting"&&!e.length?o.jsx(_l,{}):o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Live"}),o.jsxs("p",{className:"sub",children:[o.jsx("span",{className:"dot "+(n==="live"?"ok":"warn"),"aria-hidden":"true"}),n==="live"?"Connected":"Reconnecting…"," · ","last ",ou," arrivals"]})]}),e.length===0?o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"Nobody yet"}),o.jsx("p",{className:"sub",children:"Arrivals appear here the moment a camera recognises someone."})]}):o.jsx("ul",{className:"arrivals",children:e.map(u=>o.jsx(vp,{a:u},u.visit_id))})]})}function vp({a:e}){const t=e.name||e.label||"Unidentified";return o.jsxs("li",{className:"card arrival",children:[o.jsx(yp,{image:e.image,name:t}),o.jsxs("div",{className:"who-col",children:[o.jsx("strong",{children:t}),o.jsxs("span",{className:"sub",children:[e.site,e.camera_id?` · ${e.camera_id}`:""," · ",un(e.occurred_at)]}),e.attributes&&o.jsx(wp,{attrs:e.attributes})]}),e.is_new_visitor?o.jsx("span",{className:"pill new",children:"New"}):o.jsx("span",{className:"pill",children:"Returning"})]})}function yp({image:e,name:t}){return e!=null&&e.available?o.jsx("img",{className:"face",src:e.url,alt:"",loading:"lazy"}):o.jsx("span",{className:"face initials",title:(e==null?void 0:e.reason)||"","aria-hidden":"true",children:gp(t)})}function gp(e){const t=String(e).trim().split(/\s+/).filter(Boolean);return t.length?t.length===1?t[0].slice(0,2).toUpperCase():(t[0][0]+t[t.length-1][0]).toUpperCase():"?"}function wp({attrs:e}){const t=[];return e.gender&&t.push(e.gender),e.age&&t.push(`~${Math.round(e.age)}`),e.emotion&&t.push(e.emotion),t.length?o.jsx("span",{className:"attrs",children:t.join(" · ")}):null}function kp({camera:e,onClose:t}){const n=N.useRef(null),[r,l]=N.useState("waiting"),[i,s]=N.useState(!1);N.useEffect(()=>{const c=new AbortController,m=tp({cameraId:e.id,img:n,onState:h=>{l(h),h==="live"&&s(!1)},signal:c.signal}),v=setTimeout(()=>s(!0),12e3);return()=>{m(),c.abort(),clearTimeout(v)}},[e.id]);const u=e.connected===!1?"This camera was not connecting when the shop PC last reported. Check it is powered on and reachable on the shop’s network.":e.connected==null?"The shop PC has not reported on this camera yet. It may still be starting up.":"The shop PC is not sending frames. It may be offline, or its Behavision app may not be running.",a=i&&r!=="live"?u:{waiting:"Asking the shop PC…",live:"Live",reconnecting:"Reconnecting…"}[r];return o.jsx("div",{className:"overlay",onClick:t,children:o.jsxs("aside",{className:"drawer live",onClick:c=>c.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsxs("div",{children:[o.jsx("h2",{children:e.label}),o.jsx("p",{className:"sub",children:e.site})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Close"})]}),o.jsxs("div",{className:"drawer-body",children:[o.jsxs("div",{className:"liveshot",children:[o.jsx("img",{ref:n,alt:`Live view from ${e.label}`}),r!=="live"&&o.jsx("div",{className:"livewait",children:o.jsx("span",{children:i?"No picture yet":a})})]}),o.jsx("p",{className:"hint",style:{marginTop:10},children:r==="live"?"Live. The shop only uploads while this view is open.":a})]})]})})}const Ur=[{id:"hikvision",label:"Hikvision",path:"/Streaming/Channels/101",note:"Channel 1, main stream. Use /Streaming/Channels/102 for the lower-quality sub stream."},{id:"dahua",label:"Dahua",path:"/cam/realmonitor?channel=1&subtype=0",note:"Channel 1, main stream. subtype=1 is the sub stream."},{id:"cpplus",label:"CP Plus",path:"/cam/realmonitor?channel=1&subtype=0",note:"CP Plus cameras use the Dahua stream path."},{id:"uniview",label:"Uniview",path:"/media/video1",note:"Some older Uniview models use /video1 instead."},{id:"tplink",label:"TP-Link / Tapo",path:"/stream1",note:"Tapo cameras need a separate camera account created in the Tapo app — your Tapo login will not work."},{id:"reolink",label:"Reolink",path:"/h264Preview_01_main",note:"Use /h264Preview_01_sub for the lower-quality stream."},{id:"amcrest",label:"Amcrest",path:"/cam/realmonitor?channel=1&subtype=0",note:"Amcrest cameras use the Dahua stream path."},{id:"axis",label:"Axis",path:"/axis-media/media.amp",note:""},{id:"onvif",label:"Other (ONVIF)",path:"/onvif1",note:"Many generic cameras answer here. If it does not work, look for “RTSP” in the camera’s own app."},{id:"manual",label:"I know the path",path:"",note:""}],uu=e=>Ur.find(t=>t.id===e)||Ur[Ur.length-1],xp=["Camera","Connection","Test","Walk past"];function Sp({sites:e,existing:t,onClose:n,onSaved:r}){var d;const l=!(t!=null&&t.id),[i,s]=N.useState(l?0:2),[u,a]=N.useState(t!=null&&t.id?t:null),[c,m]=N.useState({site_id:(t==null?void 0:t.site_id)||((d=e[0])==null?void 0:d.site_id)||"",make:"hikvision",label:(t==null?void 0:t.label)||"",host:(t==null?void 0:t.host)||"",port:(t==null?void 0:t.port)||554,path:(t==null?void 0:t.path)||"/Streaming/Channels/101",username:(t==null?void 0:t.username)||"",password:""}),[v,h]=N.useState(!1),[y,k]=N.useState(""),w=f=>g=>m(S=>({...S,[f]:g.target.value})),T=f=>{const g=uu(f.target.value);m(S=>({...S,make:g.id,path:g.path||S.path}))},p=async()=>{h(!0),k("");const f={};for(const[g,S]of Object.entries(c))g==="site_id"||g==="make"||S===""||S==null||(f[g]=g==="port"?Number(S):S);try{const g=u!=null&&u.id?await J.updateCamera(u.id,f):await J.createCamera(c.site_id,f);a(g),s(2),r==null||r(g,{keepOpen:!0})}catch(g){k(g.message)}finally{h(!1)}};return o.jsx("div",{className:"overlay",onClick:n,children:o.jsxs("aside",{className:"drawer wizard",onClick:f=>f.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsxs("div",{children:[o.jsx("h2",{children:l?"Set up a camera":(u==null?void 0:u.label)||(t==null?void 0:t.label)}),o.jsx("p",{className:"sub",children:i<2?"The shop’s PC connects to the camera — nothing needs opening to the internet.":"Prove it works before you rely on it."})]}),o.jsx("button",{className:"ghost",onClick:n,children:"Close"})]}),o.jsx("ol",{className:"steps",children:xp.map((f,g)=>o.jsxs("li",{className:g===i?"now":go.jsx("option",{value:f.site_id,children:f.name},f.site_id))})]}),o.jsxs("label",{children:["What should staff call it?",o.jsx("input",{value:c.label,onChange:w("label"),placeholder:"Entrance",autoFocus:!0}),o.jsx("span",{className:"hint",children:"Visits are recorded against this name, so it cannot be changed later."})]}),o.jsxs("label",{children:["Make of camera",o.jsx("select",{value:c.make,onChange:T,children:Ur.map(f=>o.jsx("option",{value:f.id,children:f.label},f.id))}),o.jsx("span",{className:"hint",children:uu(c.make).note||"This only fills in the stream path for you. You can change it on the next step."})]}),o.jsx("button",{className:"primary",disabled:!c.label.trim(),onClick:()=>s(1),children:"Next"})]}),i===1&&o.jsxs("div",{className:"drawer-body",children:[o.jsxs("label",{children:["Camera’s address on the shop’s network",o.jsx("input",{value:c.host,onChange:w("host"),placeholder:"192.168.0.138",autoFocus:!0}),o.jsx("span",{className:"hint",children:"Not a website address. It usually starts 192.168. — look in the camera’s own app, on its label, or in your router’s device list."})]}),o.jsxs("div",{className:"pair",children:[o.jsxs("label",{children:["Port",o.jsx("input",{type:"number",value:c.port,onChange:w("port")})]}),o.jsxs("label",{children:["Stream path",o.jsx("input",{value:c.path,onChange:w("path")})]})]}),o.jsxs("label",{children:["Camera username",o.jsx("input",{value:c.username,onChange:w("username"),placeholder:"admin",name:"camera-account",autoComplete:"off",autoCorrect:"off",autoCapitalize:"none",spellCheck:"false"}),o.jsx("span",{className:"hint",children:"The camera’s own login, not your Behavision one."})]}),o.jsxs("label",{children:["Camera password",o.jsx("input",{type:"password",value:c.password,onChange:w("password"),name:"camera-secret",autoComplete:"new-password",placeholder:u!=null&&u.has_password?"(unchanged)":""})]}),y&&o.jsx("p",{className:"error",role:"alert",children:y}),o.jsxs("div",{className:"pair",children:[o.jsx("button",{className:"ghost",onClick:()=>s(0),children:"Back"}),o.jsx("button",{className:"primary",disabled:v||!c.host.trim(),onClick:p,children:v?"Saving…":"Save and test"})]})]}),i>=2&&u&&o.jsx(jp,{camera:u,step:i,onStep:s,onUpdated:f=>{a(f),r==null||r(f,{keepOpen:!0})},onEdit:()=>s(1),onDone:n})]})})}function jp({camera:e,step:t,onStep:n,onUpdated:r,onEdit:l,onDone:i}){const[s,u]=N.useState(e),[a,c]=N.useState(!1),[m,v]=N.useState(""),h=N.useRef(null);N.useEffect(()=>u(e),[e]);const y=s.check||{},k=y.state==="requested"||y.state==="running";N.useEffect(()=>{if(!k)return;let p=!0;const d=async()=>{try{const g=(await J.cameras()).find(S=>S.id===s.id);p&&g&&(u(g),r==null||r(g))}catch{}};return h.current=setInterval(d,4e3),()=>{p=!1,clearInterval(h.current)}},[k,s.id]);const w=async(p,d)=>{c(!0),v("");try{const f=await J.checkCamera(s.id,p,d);u(f),r==null||r(f),n(p==="placement"?3:2)}catch(f){v(f.message)}finally{c(!1)}},T=y.kind==="connection"&&y.state==="done"&&y.ok;return o.jsx(o.Fragment,{children:o.jsxs("div",{className:"drawer-body",children:[t===2?o.jsxs(o.Fragment,{children:[o.jsx("h3",{children:"Can the shop’s PC reach this camera?"}),o.jsx("p",{className:"sub",children:"The PC opens the stream once and takes a single picture. Nothing is recorded."})]}):o.jsxs(o.Fragment,{children:[o.jsx("h3",{children:"Can it actually recognise a face?"}),o.jsxs("p",{className:"sub",children:["Someone needs to ",o.jsx("b",{children:"walk through the camera’s view and out of it"}),", the way a customer would. Standing still measures nothing — the check scores the best view of each person as they leave the frame, which is what recognition really uses."]})]}),k&&o.jsxs("div",{className:"waiting",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),o.jsxs("div",{children:[o.jsx("b",{children:y.state==="running"?"Running on the shop’s PC…":"Waiting for the shop’s PC…"}),o.jsx("span",{className:"sub",children:y.state==="running"?"This takes about "+(y.seconds||25)+" seconds.":"It picks up the request within a couple of minutes."})]})]}),y.state==="done"&&o.jsx(Cp,{check:y}),m&&o.jsx("p",{className:"error",role:"alert",children:m}),o.jsx("div",{className:"pair",children:t===2?o.jsxs(o.Fragment,{children:[o.jsx("button",{className:"ghost",onClick:l,children:"Change details"}),o.jsx("button",{className:"primary",disabled:a||k,onClick:()=>w("connection"),children:y.state==="done"?"Test again":"Test connection"})]}):o.jsxs(o.Fragment,{children:[o.jsx("button",{className:"ghost",onClick:()=>n(2),children:"Back"}),o.jsx("button",{className:"primary",disabled:a||k,onClick:()=>w("placement",25),children:y.state==="done"?"Check again":"Start walk-past check"})]})}),t===2&&T&&!k&&o.jsx("button",{className:"primary",onClick:()=>n(3),children:"Next: prove it can recognise faces"}),t===3&&y.state==="done"&&!k&&o.jsx("button",{className:"ghost",onClick:i,children:"Finish"})]})})}function Cp({check:e}){var n,r,l;const t=e.ok?"ok":e.verdict==="marginal"?"warn":"bad";return o.jsxs("div",{className:"outcome "+t,children:[o.jsxs("div",{className:"outcome-head",children:[o.jsx("span",{className:"pill "+t,children:e.ok?"Working":"Not ready"}),o.jsx("b",{children:e.headline||(e.ok?"Working":"Could not be verified")})]}),((n=e.image)==null?void 0:n.available)&&o.jsx("img",{className:"proof",src:e.image.url,alt:"The view from this camera",loading:"lazy"}),((r=e.advice)==null?void 0:r.length)>0&&o.jsx("ul",{className:"advice",children:e.advice.map((i,s)=>o.jsx("li",{children:i},s))}),((l=e.detail)==null?void 0:l.faces)!=null&&o.jsxs("p",{className:"sub",children:[e.detail.faces," ",e.detail.faces===1?"person":"people"," walked past during the check."]})]})}function Np(e){const t=e.check||{};return t.state==="requested"||t.state==="running"?{tone:"idle",mark:"…",words:"Checking now"}:t.state!=="done"?{tone:"idle",mark:"?",words:"Not checked yet"}:t.kind==="placement"?t.ok?{tone:"ok",mark:"✓",words:"Recognises faces here"}:{tone:"bad",mark:"✕",words:t.headline||"Cannot recognise faces here"}:t.ok?{tone:"warn",mark:"!",words:"Stream works — faces not checked yet"}:{tone:"bad",mark:"✕",words:t.headline||"Could not reach the camera"}}function Ep({user:e}){const{data:t,error:n,loading:r,reload:l}=er(()=>J.cameras(),2e4,[]),{data:i}=er(()=>J.sites(),0,[]),[s,u]=N.useState(null),[a,c]=N.useState(null),m=["admin","owner","manager"].includes(e.role),v=t||[],h=v.filter(w=>w.connected).length,y=v.filter(w=>w.connected===!1).length,k=v.filter(w=>w.connected==null).length;return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Cameras"}),o.jsxs("p",{className:"sub",children:[v.length," ",v.length===1?"camera":"cameras",h>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"ok",children:[h," connected"]})]}),y>0&&o.jsxs(o.Fragment,{children:[" · ",o.jsxs("b",{className:"bad",children:[y," down"]})]}),k>0&&o.jsxs(o.Fragment,{children:[" · ",k," waiting for the shop PC"]})]}),m&&o.jsx("button",{className:"primary",onClick:()=>u({}),children:"Set up a camera"})]}),r&&!t?o.jsx(_l,{}):n?o.jsx(Pl,{error:n}):v.length===0?o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"No cameras yet"}),o.jsx("p",{className:"sub",children:"Add one here and the shop’s PC will pick it up within a couple of minutes. Cameras already set up on a shop PC appear here on their own."})]}):o.jsx("div",{className:"grid cams",children:v.map(w=>o.jsx(_p,{cam:w,canEdit:m,onEdit:()=>u(w),onWatch:()=>c(w)},w.id))}),s&&o.jsx(Sp,{existing:s.id?s:null,sites:i||[],onClose:()=>{u(null),l()},onSaved:(w,T)=>{T!=null&&T.keepOpen||u(null),l()}}),a&&o.jsx(kp,{camera:a,onClose:()=>c(null)})]})}function _p({cam:e,canEdit:t,onEdit:n,onWatch:r}){var u,a;const l=e.connected==null?"idle":e.connected?"ok":"bad",i=e.connected==null?"Waiting for the shop PC":e.connected?"Connected":"Not connecting",s=Np(e);return o.jsxs("article",{className:"card cam state-"+l,onClick:t?n:void 0,role:t?"button":void 0,tabIndex:t?0:void 0,onKeyDown:c=>t&&c.key==="Enter"&&n(),children:[o.jsxs("div",{className:"shot",children:[(u=e.snapshot)!=null&&u.available?o.jsx(Cc,{url:e.snapshot.url,alt:`View from ${e.label}`}):o.jsxs("div",{className:"noshot",children:[o.jsx("span",{className:"lens","aria-hidden":"true"}),((a=e.snapshot)==null?void 0:a.reason)||"No picture yet."]}),o.jsxs("div",{className:"shot-over",children:[o.jsxs("div",{className:"shot-name",children:[o.jsx("b",{children:e.label||e.camera_id}),o.jsx("span",{children:e.site})]}),o.jsxs("span",{className:"status "+l,children:[o.jsx("i",{"aria-hidden":"true"}),i]})]}),e.snapshot_at&&o.jsx("span",{className:"shot-age",children:un(e.snapshot_at)}),o.jsxs("button",{className:"watch",title:"Watch this camera now",onClick:c=>{c.stopPropagation(),r()},children:[o.jsx("i",{"aria-hidden":"true"}),"Live"]})]}),o.jsxs("div",{className:"verdict "+s.tone,children:[o.jsx("span",{className:"mark","aria-hidden":"true",children:s.mark}),o.jsx("span",{className:"words",children:s.words}),t&&o.jsx("span",{className:"go","aria-hidden":"true",children:"→"})]})]})}const Pp=["Is everything working today?","Why is footfall low at Chennai?","How many people came in last week?","Which cameras still need checking?"];function Tp({open:e,onClose:t}){const[n,r]=N.useState([]),[l,i]=N.useState(""),[s,u]=N.useState(!1),[a,c]=N.useState(!1),m=N.useRef(null),v=N.useRef(null);N.useEffect(()=>{var y;e&&((y=v.current)==null||y.focus())},[e]),N.useEffect(()=>{var y;(y=m.current)==null||y.scrollIntoView({behavior:"smooth",block:"end"})},[n,s]);const h=async y=>{const k=(y??l).trim();if(!k||s)return;const w=[...n,{role:"user",text:k}];r(w),i(""),u(!0);try{const T=await J.ask(w);r(p=>[...p,{role:"assistant",text:T.text,used:T.used}])}catch(T){T.code==="assistant_off"?c(!0):r(p=>[...p,{role:"assistant",text:T.message,failed:!0}])}finally{u(!1)}};return e?o.jsxs("aside",{className:"assistant",role:"complementary","aria-label":"Assistant",children:[o.jsxs("header",{className:"assistant-head",children:[o.jsxs("div",{children:[o.jsx("b",{children:"Ask Behavision"}),o.jsx("span",{className:"sub",children:"It reads your shops’ own data to answer."})]}),o.jsx("button",{className:"ghost",onClick:t,"aria-label":"Close assistant",children:"✕"})]}),o.jsxs("div",{className:"assistant-body",children:[a?o.jsx("p",{className:"sub pad",children:"The assistant is not switched on for this server."}):n.length===0?o.jsxs("div",{className:"suggest",children:[o.jsx("p",{className:"sub",children:"Try asking:"}),Pp.map(y=>o.jsx("button",{className:"chip",onClick:()=>h(y),children:y},y))]}):n.map((y,k)=>{var w;return o.jsxs("div",{className:"bubble "+y.role+(y.failed?" failed":""),children:[y.text,((w=y.used)==null?void 0:w.length)>0&&o.jsxs("span",{className:"used",children:["looked at: ",y.used.map(zp).join(", ")]})]},k)}),s&&o.jsxs("div",{className:"bubble assistant-thinking",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"})," Looking…"]}),o.jsx("div",{ref:m})]}),!a&&o.jsxs("form",{className:"assistant-ask",onSubmit:y=>{y.preventDefault(),h()},children:[o.jsx("input",{ref:v,value:l,onChange:y=>i(y.target.value),placeholder:"Ask about your shops…",disabled:s}),o.jsx("button",{className:"primary",disabled:s||!l.trim(),children:"Ask"})]})]}):null}function zp(e){return{list_shops:"your shops",check_shop:"a shop check",list_cameras:"your cameras",check_camera:"a camera check",footfall:"footfall",sales:"sales",find_customer:"customer records"}[e]||e}function Lp(){const{data:e,error:t,loading:n,reload:r}=er(()=>J.clients(),0,[]),[l,i]=N.useState(!1),[s,u]=N.useState(null),a=e||[];return o.jsxs(o.Fragment,{children:[o.jsxs("header",{className:"head",children:[o.jsx("h1",{children:"Companies"}),o.jsx("button",{className:"primary",onClick:()=>{u(null),i(!0)},children:"New company"})]}),s&&o.jsx(Op,{result:s,onDismiss:()=>u(null)}),n&&!e?o.jsx(_l,{}):t?o.jsx(Pl,{error:t}):a.length===0?o.jsxs("div",{className:"state",children:[o.jsx("h2",{children:"No companies yet"}),o.jsx("p",{className:"sub",children:"Create one, and its owner can sign in straight away."})]}):o.jsx("div",{className:"tablewrap",children:o.jsxs("table",{className:"rows",children:[o.jsx("thead",{children:o.jsxs("tr",{children:[o.jsx("th",{children:"Company"}),o.jsx("th",{children:"Short name"}),o.jsx("th",{children:"Sites"}),o.jsx("th",{children:"People"}),o.jsx("th",{children:"Created"})]})}),o.jsx("tbody",{children:a.map(c=>o.jsxs("tr",{children:[o.jsx("td",{children:o.jsx("strong",{children:c.name})}),o.jsx("td",{children:o.jsx("code",{children:c.slug})}),o.jsx("td",{className:"num",children:c.sites}),o.jsx("td",{className:"num",children:c.users}),o.jsx("td",{className:"sub",children:un(c.created_at)})]},c.id))})]})}),l&&o.jsx(Rp,{onClose:()=>i(!1),onCreated:c=>{i(!1),u(c),r()}})]})}function Rp({onClose:e,onCreated:t}){const[n,r]=N.useState({company_name:"",owner_name:"",owner_email:"",password:""}),[l,i]=N.useState(!1),[s,u]=N.useState(""),a=m=>v=>r({...n,[m]:v.target.value}),c=async m=>{m.preventDefault(),i(!0),u("");try{t(await J.createClient(n))}catch(v){u(v.message),i(!1)}};return o.jsx("div",{className:"overlay",onClick:e,children:o.jsxs("aside",{className:"drawer narrow",onClick:m=>m.stopPropagation(),children:[o.jsxs("header",{className:"drawer-head",children:[o.jsx("h2",{children:"New company"}),o.jsx("button",{className:"ghost",onClick:e,children:"Close"})]}),o.jsxs("form",{className:"drawer-body",onSubmit:c,children:[o.jsxs("label",{children:["Company name",o.jsx("input",{value:n.company_name,onChange:a("company_name"),required:!0,autoFocus:!0})]}),o.jsxs("label",{children:["Owner’s name",o.jsx("input",{value:n.owner_name,onChange:a("owner_name")})]}),o.jsxs("label",{children:["Owner’s email",o.jsx("input",{type:"email",value:n.owner_email,onChange:a("owner_email"),required:!0})]}),o.jsxs("label",{children:["Password",o.jsx("input",{type:"text",value:n.password,onChange:a("password"),placeholder:"Leave empty to generate one"}),o.jsx("span",{className:"hint",children:"Generated is better — a password you invent for someone else ends up weak and sent over chat."})]}),s&&o.jsx("p",{className:"error",role:"alert",children:s}),o.jsx("button",{className:"primary",disabled:l,children:l?"Creating…":"Create company"})]})]})})}function Op({result:e,onDismiss:t}){return o.jsxs("div",{className:"banner ok credentials",children:[o.jsxs("div",{children:[o.jsxs("b",{children:[e.slug," created."]})," These sign-in details are shown once and cannot be recovered. Send them to the owner now.",o.jsxs("dl",{className:"creds",children:[o.jsxs("div",{children:[o.jsx("dt",{children:"Email"}),o.jsx("dd",{children:o.jsx("code",{children:e.owner_email})})]}),o.jsxs("div",{children:[o.jsx("dt",{children:"Password"}),o.jsx("dd",{children:o.jsx("code",{children:e.password})})]})]})]}),o.jsx("button",{className:"ghost",onClick:t,children:"Done"})]})}const Ip=[{id:"sites",label:"Shops",View:up},{id:"live",label:"Live",View:mp},{id:"cameras",label:"Cameras",View:Ep}],Fp=[{id:"clients",label:"Companies",View:Lp}];function Dp(){const[e,t]=N.useState(null),[n,r]=N.useState(!0),[l,i]=N.useState("sites"),[s,u]=N.useState(!1);if(N.useEffect(()=>{(async()=>{if(bf())try{t(await J.me())}catch{or()}r(!1)})()},[]),n)return o.jsxs("div",{className:"boot",children:[o.jsx("span",{className:"spinner","aria-hidden":"true"}),"Loading…"]});if(!e)return o.jsx(np,{onSignedIn:t});const a=e.role==="admin"&&!e.client_id,c=a?Fp:Ip,m=c.find(y=>y.id===l)||c[0],v=m.View,h=async()=>{await J.logout(),t(null)};return o.jsxs("div",{className:"app",children:[o.jsxs("header",{className:"topbar",children:[o.jsxs("div",{className:"brand",children:[o.jsx("span",{className:"mark","aria-hidden":"true"}),o.jsxs("div",{children:[o.jsx("strong",{children:"Behavision"}),o.jsx("span",{className:"org",children:e.client_name||"Loyaly platform"})]})]}),o.jsx("nav",{className:"tabs",children:c.map(y=>o.jsx("button",{onClick:()=>i(y.id),"aria-current":m.id===y.id?"page":void 0,children:y.label},y.id))}),o.jsxs("div",{className:"who",children:[o.jsxs("button",{className:"ask-btn"+(s?" on":""),onClick:()=>u(y=>!y),children:[o.jsx("span",{"aria-hidden":"true",children:"✳"})," Ask"]}),o.jsx("span",{className:"name",children:e.full_name||e.email}),o.jsx("span",{className:"role",children:a?"platform admin":e.role}),o.jsx("button",{className:"ghost",onClick:h,children:"Sign out"})]})]}),o.jsxs("div",{className:"with-assistant"+(s?" open":""),children:[o.jsx("main",{className:"page",children:o.jsx(v,{user:e})}),o.jsx(Tp,{open:s,onClose:()=>u(!1)})]})]})}xc(document.getElementById("root")).render(o.jsx(Dp,{})); diff --git a/server/internal/web/dist/index.html b/server/internal/web/dist/index.html index 3460ec3..85fe50a 100644 --- a/server/internal/web/dist/index.html +++ b/server/internal/web/dist/index.html @@ -5,8 +5,8 @@ Behavision - - + +
diff --git a/server/migrations/010_invitations.sql b/server/migrations/010_invitations.sql new file mode 100644 index 0000000..9c3f92a --- /dev/null +++ b/server/migrations/010_invitations.sql @@ -0,0 +1,60 @@ +-- Adding a second person to a company. +-- +-- Until now a tenant had exactly the users `provision user` had created on the +-- server's own command line. That is not a gap in a UI, it is a gap in the +-- product: a shop with an owner and four staff either shares one password +-- between five people or raises a support ticket to add each of them, and a +-- mobile app for shop floor staff cannot exist at all when there is only one +-- account to sign in with. +-- +-- Registration is by INVITATION, never open signup. That is the same line +-- `handlers_admin.go` already draws for creating a company: an endpoint a +-- stranger can call to create an account is a much larger thing to secure than +-- one reachable only through a manager who already has one, and a self-created +-- account in a tenant is a row nobody asked for holding a place in a table +-- every query joins against. +-- +-- The single-use guarantee is the same one enrolment codes use, and for the +-- same reason: it lives in the UPDATE (`used_at IS NULL` and the write are one +-- statement), never in a check followed by a write, so two people racing on one +-- invitation cannot both win. + +BEGIN; + +CREATE TABLE IF NOT EXISTS invitations ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + -- The address the invitation was issued FOR. It becomes the account's + -- address on redemption and is not caller-supplied at that point: letting + -- the redeemer choose would turn one invitation into an account for + -- anybody who was forwarded the email. + email text NOT NULL, + full_name text NOT NULL DEFAULT '', + -- 'admin' is deliberately NOT allowed. A platform administrator is defined + -- by having no client at all, so an invitation - which always carries one - + -- could never mint a real one; what it could do is put the string 'admin' + -- on a tenant-scoped row, and `adminOnly` guards against exactly that + -- combination existing. Refusing it here means it cannot be created in the + -- first place. + role text NOT NULL DEFAULT 'staff', + -- Only the hash. An invitation is a credential for as long as it is + -- unused, so a database dump must not contain a working one - the same + -- rule sessions and enrolment codes already follow. + code_hash bytea NOT NULL UNIQUE, + invited_by uuid REFERENCES app_users(id) ON DELETE SET NULL, + expires_at timestamptz NOT NULL, + used_at timestamptz, + used_by uuid REFERENCES app_users(id) ON DELETE SET NULL, + revoked_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + CONSTRAINT invitations_role_known + CHECK (role IN ('owner', 'manager', 'staff')) +); + +-- Pending invitations only. The list a manager looks at is "who has been asked +-- and has not joined yet"; spent and revoked rows are history. +CREATE INDEX IF NOT EXISTS invitations_pending_idx + ON invitations (client_id, created_at DESC) + WHERE used_at IS NULL AND revoked_at IS NULL; + +COMMIT; diff --git a/server/migrations/011_visit_faces.sql b/server/migrations/011_visit_faces.sql new file mode 100644 index 0000000..1ca8258 --- /dev/null +++ b/server/migrations/011_visit_faces.sql @@ -0,0 +1,60 @@ +-- Face images for a deployment that has no object storage. +-- +-- 009 did this for camera snapshots and its own comment says why face images +-- are different: "Face images grow with every visitor who ever walks in, which +-- is why they stay in a bucket." That is true of face images kept PER VISIT, +-- and it is the reason this table is bounded to one row per visitor instead. +-- +-- The problem it fixes is the one 009 fixed one level up. With no bucket the +-- API answers "This system is not storing customer photos" for every arrival, +-- forever - including on the mobile feed, whose entire purpose is to put a face +-- in front of somebody so they can recognise the customer walking towards them. +-- A shop that turned `app.store_faces` on and has no S3 account got nothing. +-- +-- What makes this bounded, which is the only reason it is acceptable here: +-- +-- * The engine still gates capture. `app.store_faces` is false by default and +-- no crop is written without it, so this table changes what happens to an +-- image that already exists - it does not change whether one is taken. +-- * ONE ROW SURVIVES PER VISITOR. `RecordVisit` prunes the previous row when +-- it links a newer one, so storage is (customers x ~20 KB) and grows with +-- the customer base, not with footfall. A shop seen by 5,000 people holds +-- about 100 MB whether they visit once or a thousand times. +-- * Nothing reads a superseded face anyway. Every surface - the arrivals +-- feed, the customer record, the mobile app - shows the customer's latest +-- view, which is what `VisitorImageKey` has always returned. +-- +-- Where a bucket IS configured this table is never written: the presigned path +-- stays primary, because it never passes the bytes through the API at all, +-- which is what makes it the right route at estate scale. +-- +-- Keys are prefixed `db:` in `visits.image_key` so one column can name an +-- object in either place and the read path can tell which without a second +-- lookup or a nullable column. + +BEGIN; + +CREATE TABLE IF NOT EXISTS visit_faces ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + -- Denormalised like every other table here: a cross-tenant read should + -- require a wrong WHERE clause rather than a forgotten join. + client_id uuid NOT NULL REFERENCES clients(id) ON DELETE CASCADE, + site_id uuid NOT NULL REFERENCES sites(id) ON DELETE CASCADE, + image bytea NOT NULL, + bytes integer NOT NULL, + captured_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX IF NOT EXISTS visit_faces_client_idx + ON visit_faces (client_id); + +-- An agent uploads a face BEFORE the server has decided who it is, so a row can +-- exist for a few milliseconds with no visit pointing at it - and permanently, +-- if the visit that would have claimed it never arrives because the queue was +-- dropped. That is a leak of exactly one image per lost visit, so it is swept +-- rather than left: anything older than a day with no visit referencing it is +-- an orphan, and this index is what makes finding them cheap. +CREATE INDEX IF NOT EXISTS visit_faces_age_idx + ON visit_faces (captured_at); + +COMMIT; diff --git a/web/src/App.jsx b/web/src/App.jsx index aebad1d..c66544a 100644 --- a/web/src/App.jsx +++ b/web/src/App.jsx @@ -6,6 +6,7 @@ import Live from './views/Live.jsx' import CamerasView from './views/Cameras.jsx' import Assistant from './views/Assistant.jsx' import Clients from './views/Clients.jsx' +import Team from './views/Team.jsx' // A platform admin has no client of their own, so the tenant screens have // nothing to show them. Rather than render empty pages, they get the one screen @@ -22,6 +23,7 @@ const TENANT_VIEWS = [ { id: 'sites', label: 'Shops', View: Sites }, { id: 'live', label: 'Live', View: Live }, { id: 'cameras', label: 'Cameras', View: CamerasView }, + { id: 'team', label: 'Team', View: Team }, ] const ADMIN_VIEWS = [ { id: 'clients', label: 'Companies', View: Clients }, diff --git a/web/src/api.js b/web/src/api.js index 528369c..8c7953d 100644 --- a/web/src/api.js +++ b/web/src/api.js @@ -122,6 +122,23 @@ async function fetchImage(path, retry = true) { 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 || {})) { @@ -136,7 +153,7 @@ export const api = { const res = await fetch('/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password }), + body: JSON.stringify({ email, password, device: deviceName() }), }) const body = await res.json().catch(() => null) if (!res.ok) { @@ -147,6 +164,39 @@ export const api = { 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() @@ -196,6 +246,25 @@ export const api = { 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. diff --git a/web/src/styles.css b/web/src/styles.css index 2cba8bd..7d78b18 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -182,7 +182,12 @@ button.ghost:hover { border-color: var(--muted); color: var(--ink); } .arrivals { list-style: none; padding: 0; display: grid; gap: 8px; } .card.arrival { display: flex; align-items: center; gap: 14px; padding: 11px 14px; } .face { width: 46px; height: 46px; border-radius: 50%; flex: none; - object-fit: cover; background: var(--surface-2); } + overflow: hidden; object-fit: cover; background: var(--surface-2); } +/* .face is a wrapping the picture rather than the itself, because + a face served from this server's own database has to be fetched with the + session before it can be shown. The image inside still has to fill the + circle, and the wrapper clips it. */ +.face > img { width: 100%; height: 100%; object-fit: cover; display: block; } .face.initials { display: grid; place-items: center; color: var(--muted); font-size: 15px; font-weight: 600; letter-spacing: .02em; } .who-col { display: flex; flex-direction: column; gap: 1px; flex: 1; min-width: 0; } @@ -536,3 +541,30 @@ button.ghost.danger:hover { border-color: var(--bad); } font-size: 13px; color: rgba(255, 255, 255, .78); background: rgba(0, 0, 0, .35); } + +/* ---------------------------------------------------------------- team --- */ +.rows tr.inactive td { opacity: .55; } +.rows .role { text-transform: capitalize; } +.rows td.right { text-align: right; } +.pill.muted { margin-left: 8px; font-size: 11px; padding: 1px 7px; border-radius: 999px; + background: var(--surface-2); color: var(--muted); vertical-align: middle; } +.pending { margin-top: 26px; } +.pending h2 { font-size: 14px; font-weight: 600; color: var(--muted); margin: 0 0 10px; } +.invites { list-style: none; padding: 0; display: grid; gap: 8px; } +.card.invite { display: flex; align-items: center; justify-content: space-between; + gap: 14px; padding: 11px 14px; } +.card.invite .sub { display: block; } +/* The code is read aloud and typed in, so it is set wide and monospaced. + Grouped in sixes by the server for the same reason. */ +.creds code.big { font-size: 16px; letter-spacing: .06em; } + +/* A button that reads as a link. Used where the action is a change of screen + rather than a submission, so it must not look like the primary button next + to it. */ +.linkish { background: none; border: 0; padding: 0; font: inherit; + color: var(--accent); cursor: pointer; text-decoration: underline; + text-underline-offset: 2px; } +.linkish:hover { opacity: .8; } +/* The code is read off a screen or a phone call, so it is set wide. */ +.codefield { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + letter-spacing: .04em; text-transform: uppercase; } diff --git a/web/src/views/Cameras.jsx b/web/src/views/Cameras.jsx index f8998b6..5508065 100644 --- a/web/src/views/Cameras.jsx +++ b/web/src/views/Cameras.jsx @@ -125,7 +125,7 @@ function CameraCard({ cam, canEdit, onEdit, onWatch }) { onKeyDown={e => canEdit && e.key === 'Enter' && onEdit()}>
{cam.snapshot?.available - ? + ? :
+ ) +} + +// Redeeming an invitation. +// +// Two steps deliberately. The code is checked FIRST, so somebody who has +// mistyped it finds out before choosing a password — and so the screen can say +// which company they are joining, which is the only thing that makes "is this +// the right code" answerable by the person holding it. +function Join({ onSignedIn, onCancel }) { + const [code, setCode] = useState('') + const [invite, setInvite] = useState(null) + const [fullName, setFullName] = useState('') + const [password, setPassword] = useState('') + const [confirm, setConfirm] = useState('') + const [error, setError] = useState('') + const [busy, setBusy] = useState(false) + + const check = async (e) => { + e.preventDefault() + setBusy(true); setError('') + try { + const prev = await api.previewInvitation(code.trim()) + setInvite(prev) + setFullName(prev.full_name || '') + } catch (err) { + // Unknown, expired, spent and withdrawn are one message from the server. + // The difference only helps somebody guessing codes, and the next step is + // the same in all four cases: ask for a new one. + setError(err.message) + } finally { + setBusy(false) + } + } + + const join = async (e) => { + e.preventDefault() + if (password !== confirm) { + setError('Those two passwords are not the same.') + return + } + setBusy(true); setError('') + try { + // The address and the role are not sent. They belong to the invitation. + const user = await api.register({ code: code.trim(), full_name: fullName, password }) + onSignedIn(user) + } catch (err) { + setError(err.message) + setBusy(false) + } + } + + return ( +
+
+
diff --git a/web/src/views/Shot.jsx b/web/src/views/Shot.jsx index 2910102..dd82524 100644 --- a/web/src/views/Shot.jsx +++ b/web/src/views/Shot.jsx @@ -1,24 +1,45 @@ import { useAuthedImage } from '../hooks.js' -// One camera picture, however this deployment stores them. +// One picture from the API, however this deployment stores them. // // Two shapes arrive here and they need different handling, which is exactly // why it is one component rather than an repeated on each screen: // -// * An ABSOLUTE url is a presigned link to object storage. It carries its -// own signature, so a plain loads it. -// * A RELATIVE url is served by this server from its own database, for a -// deployment with no bucket. An cannot send an Authorization header, -// so it has to be fetched with the session and handed over as an object -// URL. Minting an unauthenticated link instead would put a photograph of -// somebody's shop floor behind no session at all, which is the thing this -// path exists to avoid. -export default function Shot({ url, alt }) { - const local = typeof url === 'string' && url.startsWith('/') +// * A presigned link to object storage carries its own signature, so a plain +// loads it. +// * A picture this server holds itself - for a deployment with no bucket - +// is served from an endpoint that requires the session. An cannot +// send an Authorization header, so it has to be fetched and handed over as +// an object URL. Minting an unauthenticated link instead would put a +// photograph of somebody's shop floor, or of a customer, behind no session +// at all, which is the thing that path exists to avoid. +// +// Which one it is comes from the API's own `auth` flag, not from the shape of +// the URL. Guessing by whether it starts with "/" is right today and stops +// being right the first time object storage is served from this same host - +// and the failure then is a photograph that silently will not load. +export default function Shot({ image, url, alt }) { + // `image` is the whole object from the API; `url` is the older call shape, + // kept working so a screen that has not been updated still renders. The + // fallback heuristic applies only when nothing told us. + const src0 = image ? image.url : url + // Either signal is enough, and that is not belt-and-braces. A RELATIVE url is + // served by this server and always needs the session - there is no such thing + // as a public one - so it is sufficient on its own, and a caller that rebuilds + // an image object and loses `auth` cannot turn a working picture into a broken + // one. (It did exactly that once: Sites.jsx returned `{url, at}` from its + // snapshot picker, the flag went missing, and every shop card showed a broken + // image.) The FLAG is what adds the case the URL cannot express: an absolute + // link that still needs a bearer, which happens the first time object storage + // is served from this same host. + const needsAuth = + (image && !!image.auth) || + (typeof src0 === 'string' && src0.startsWith('/')) + // Hooks cannot be called conditionally, so this always runs and simply has // nothing to do when the URL is already usable. - const fetched = useAuthedImage(local ? url : null) - const src = local ? fetched : url + const fetched = useAuthedImage(needsAuth ? src0 : null) + const src = needsAuth ? fetched : src0 if (!src) return null return {alt} } diff --git a/web/src/views/Sites.jsx b/web/src/views/Sites.jsx index 7cfbe0b..d3df971 100644 --- a/web/src/views/Sites.jsx +++ b/web/src/views/Sites.jsx @@ -135,7 +135,7 @@ function SiteCard({ site, cams, verdict, onCheck }) { tabIndex={0} onKeyDown={e => e.key === 'Enter' && onCheck()}>
{view.url - ? + ? :
{view.reason && {view.reason}} @@ -205,7 +205,11 @@ function bestView(cams) { if (!c.snapshot?.available || !c.snapshot.url) continue if (!best || (c.snapshot_at || '') > (best.snapshot_at || '')) best = c } - if (best) return { url: best.snapshot.url, at: best.snapshot_at } + // The WHOLE snapshot object, not just its url. It carries `auth`, which says + // whether the picture has to be fetched with the session or can be handed + // straight to an - and rebuilding a partial copy here is how that flag + // gets silently dropped on one screen and not another. + if (best) return { ...best.snapshot, at: best.snapshot_at } const reason = cams.map(c => c.snapshot?.reason).find(Boolean) return { reason: reason || 'No picture from this shop yet.' } } @@ -237,6 +241,25 @@ export function ago(iso) { return `${Math.round(hrs / 24)} days ago` } +// How long until a moment in the future. +// +// `ago` clamps at zero and reads a future timestamp as "just now", which is +// right for a heartbeat whose clock is a little ahead and completely wrong for +// an expiry: a code valid for a week rendered as "expires just now", which +// tells the operator not to bother handing it over. +export function until(iso) { + if (!iso) return 'never' + const then = new Date(iso).getTime() + if (Number.isNaN(then)) return '—' + const secs = (then - Date.now()) / 1000 + if (secs <= 0) return 'expired' + const mins = Math.round(secs / 60) + if (mins < 60) return `in ${mins} min` + const hrs = Math.round(mins / 60) + if (hrs < 48) return `in ${hrs} h` + return `in ${Math.round(hrs / 24)} days` +} + export function Loading() { return
} diff --git a/web/src/views/Team.jsx b/web/src/views/Team.jsx new file mode 100644 index 0000000..d1070b2 --- /dev/null +++ b/web/src/views/Team.jsx @@ -0,0 +1,214 @@ +import { useState } from 'react' +import { api } from '../api.js' +import { usePolled } from '../hooks.js' +import { ago, until, Loading, Problem } from './Sites.jsx' + +// The people who work here, and how somebody new gets an account. +// +// Registration is by invitation, never open signup — the same line the platform +// draws around creating a company. What was missing was not openness: it was +// that a shop could not add a SECOND person at all without somebody running a +// command on the server, so five members of staff shared one password and a +// phone app for the shop floor could not exist. +// +// A manager mints a code and hands it over; the holder chooses their own +// password. The code carries the address and the role, so passing it on cannot +// turn a staff invitation into an owner account for whoever received it. +export default function Team({ user }) { + const team = usePolled(() => api.team(), 0, []) + const invites = usePolled(() => api.invitations(), 0, []) + const [inviting, setInviting] = useState(false) + const [minted, setMinted] = useState(null) + const [busy, setBusy] = useState('') + const [error, setError] = useState('') + + const canManage = user.role === 'owner' || user.role === 'manager' + const members = team.data || [] + const pending = invites.data || [] + + const change = async (id, changes) => { + setBusy(id); setError('') + try { + await api.updateMember(id, changes) + team.reload() + } catch (err) { + setError(err.message) + } finally { + setBusy('') + } + } + + return ( + <> +
+

Team

+ {canManage && ( + + )} +
+ + {minted && setMinted(null)} />} + {error &&

{error}

} + + {team.loading && !team.data ? : + team.error ? : ( +
+ + + + {canManage && + + + {members.map(m => ( + + + + + + {canManage && ( + + )} + + ))} + +
NameEmailRoleLast signed in}
{m.full_name || '—'} + {!m.active && No access}{m.email}{canManage && m.id !== user.id ? ( + + ) : {m.role}}{m.last_login_at ? ago(m.last_login_at) : 'Never'} + {m.id === user.id ? null : m.active ? ( + + ) : ( + + )} +
+
+ )} + + {canManage && pending.length > 0 && ( +
+

Waiting to join

+
    + {pending.map(i => ( +
  • +
    + {i.email} + + invited as {i.role} + {i.invited_by ? ` by ${i.invited_by}` : ''} · expires {until(i.expires_at)} + +
    + +
  • + ))} +
+
+ )} + + {inviting && ( + setInviting(false)} + onDone={(inv) => { setInviting(false); setMinted(inv); invites.reload() }} + /> + )} + + ) +} + +function InviteForm({ canMintOwner, onClose, onDone }) { + const [form, setForm] = useState({ email: '', full_name: '', role: 'staff' }) + 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 { + onDone(await api.invite(form)) + } catch (err) { + setError(err.message) + setBusy(false) + } + } + + return ( +
+ +
+ ) +} + +// Shown once, and it says so. Only a hash is stored, so this cannot be read +// back later — the same rule every other secret in this product follows, and +// the reason is the same: a code support can look up is a code anybody with +// support access can redeem. +function InviteCode({ invite, onDismiss }) { + return ( +
+
+ Invitation for {invite.email}. Give them this code. It is shown + once, works once, and cannot be recovered. +
+
Code
{invite.code}
+
Role
{invite.role}
+
+

+ They open the app, choose “I have an invitation code”, and pick their + own password. Nobody else ever sees it. +

+
+ +
+ ) +}