A merchant can create a salesperson's login and hand it over

The flow this product is sold on is three tiers: the platform admin
registers a merchant, the merchant registers their sales staff, the
staff sign in on a phone. Tier 1 handed the new owner a password. Tier 2
could not - a manager could only mint an invitation code, which the
salesperson had to redeem themselves, on their own phone, choosing their
own password. Good practice, and no use to a manager setting somebody up
before their first shift with a card and a pen.

POST /api/team/members mirrors POST /api/admin/clients: generated
password unless one is given, returned exactly once, bcrypt-hashed on
the way in and not recoverable after. Same permission shape as an
invitation - manager and above, only an owner mints an owner, admin
refused - so a manager cannot do through one door what they are refused
at the other. The invitation path stays; it is the better one whenever
the salesperson has their phone.

POST /api/team/{id}/password is the everyday case on a shop floor:
they forgot it. It sets a new one AND revokes every session they hold,
in one transaction, because the other reason a manager resets a
password is a lost phone, and a reset that left that phone signed in
would look complete while fixing nothing. Tenant-scoped in the UPDATE
itself; another company's user id is 404, never 403. No self-service
and no reset-by-email, deliberately: a floor account often has no
mailbox anyone checks, and the person who can vouch for the salesperson
standing in front of them is their manager.

RandomPassword moves from a private helper in the store to auth, so the
admin path, the merchant path and the reset all mint the same 80-bit
credential - rather than someone later writing a shorter one for the
"less important" account.

Verified: eight handler tests, and two against a real Postgres for the
things a fake cannot see - the RETURNING list scans on a row with no
last_login_at, the tenant scope holds, and the sessions row is actually
revoked. The tenant cleanup from yesterday held throughout.

API.md now documents the chain with both paths, and the note saying a
merchant could not create a login directly is gone because it is no
longer true.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KGcjxF1cNLcuwc3DAPcnfj
This commit is contained in:
2026-09-11 12:12:54 +05:30
parent c50a74de47
commit 92b12bcb1c
10 changed files with 696 additions and 29 deletions

102
API.md
View File

@@ -25,7 +25,7 @@ everything the one below can:
| role | can additionally |
|---|---|
| `staff` | see arrivals, search customers, edit a customer's profile, record a purchase |
| `manager` | manage cameras, invite and remove team members, issue shop-PC installation codes, erase a customer |
| `manager` | manage cameras, create / invite / reset / remove team members, issue shop-PC installation codes, erase a customer |
| `owner` | promote somebody to owner |
A platform admin has `role: "admin"` **and an empty `client_id`** — both
@@ -51,7 +51,7 @@ user; the tenant is always taken from the session and never from the request.
| `POST /api/sites/{site}/cameras` · `PATCH` / `DELETE /api/cameras/{id}` · `POST /api/cameras/{id}/check` | manager |
| `POST /api/sites/{site}/enrolment-code` | manager |
| `GET /api/team` | authed (tenant users only) |
| `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
| `POST /api/team/members` · `POST /api/team/{id}/password` · `PATCH /api/team/{id}` · `/api/team/invitations*` | manager |
| `GET /api/reports/footfall` · `GET /api/reports/conversion` | authed |
| `POST /api/assistant` | authed |
| `GET` / `POST /api/admin/clients` | **platform admin** |
@@ -69,12 +69,14 @@ Three tiers. Each one creates the login for the next, and nobody ever creates
their own from nothing.
```
┌──────────────────┐ creates ┌──────────────────┐ invites ┌──────────────────┐
┌──────────────────┐ creates ┌──────────────────┐ creates ┌──────────────────┐
│ Platform admin │ ───────────▶ │ Merchant owner │ ───────────▶ │ Sales staff │
│ (web) │ company + │ (web) │ code, │ (mobile) │
│ (web) │ company + │ (web) │ login, pw │ (mobile) │
│ │ owner login │ │ shown once │ │
└──────────────────┘ └──────────────────┘ └──────────────────┘
POST /api/admin/clients POST /api/team/invitations POST /api/auth/register
POST /api/admin/clients POST /api/team/members POST /api/auth/login
(or /api/team/invitations → (or /api/auth/register
a code they redeem themselves) with the code)
```
### Tier 1 — the platform admin registers a merchant
@@ -103,12 +105,33 @@ GET /api/admin/clients → every merchant, with site and user counts
### Tier 2 — the merchant owner registers sales staff
Signed in as the owner (or any manager).
Signed in as the owner (or any manager). Two ways to do it; use whichever fits
the moment.
**Directly — create the login and hand it over.** For a salesperson being set
up before their first shift, without a phone in hand. Exactly how the admin
created the merchant in Tier 1.
```
POST /api/auth/login
{ "email": "suriya@tenext.in", "password": "xK9…", "device": "Head office" }
POST /api/team/members
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff" }
→ 201 { "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "active": true, "created_at": "…",
"password": "m4kq…" } ← shown ONCE
```
Leave `password` out and one is generated; give one and it is used (8
characters minimum). Either way it is returned exactly once — write it on the
card now. The salesperson signs in on their phone with that email and
password, and the merchant login is done.
**By invitation — the salesperson chooses their own password.** Better when
they have their phone: the merchant never sees or handles a staff password.
```
POST /api/team/invitations
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
"expires_in_days": 7 }
@@ -117,9 +140,19 @@ POST /api/team/invitations
```
`code` is shown **once** and is what the merchant gives the salesperson —
read aloud, WhatsApp, printed on a card. It is single-use and expires. The
salesperson chooses their own password when they redeem it (Tier 3), so the
merchant never knows or handles a staff password.
read aloud, WhatsApp, printed on a card. Single-use, expires. They redeem it in
Tier 3 and pick a password there.
**When they forget it** — which is the everyday case on a shop floor:
```
POST /api/team/{id}/password { } or { "password": "chosen" }
→ 200 { "password": "n7xw…" } ← shown ONCE
```
Resets the password **and signs them out of every device** in one step,
because the other reason a manager resets a password is a lost phone, and a
reset that left that phone signed in would look complete while fixing nothing.
Managing the team afterwards:
@@ -137,7 +170,9 @@ The owner also sets the shop up from the same login — `POST
### Tier 3 — the salesperson gets their mobile login
Not signed in yet. They have the code.
If the merchant created the login directly, they already have an email and
password: skip straight to `POST /api/auth/login` below. Otherwise they have a
code.
```
GET /api/auth/invitation?code=LQOUHR-AYYTPE-7Q756N-PGAAN6 (no auth)
@@ -180,13 +215,6 @@ somebody else's account.
### What does not exist, stated plainly
- **The merchant cannot create a staff login directly** with a password of
their choosing. The only path is an invitation code the staff member redeems
themselves. This is deliberate — it keeps staff passwords out of the
merchant's hands and off chat — but it means a salesperson without a phone in
hand cannot be set up *for* them. If that friction is real, a
`POST /api/team/members` that mirrors `POST /api/admin/clients` (generated
password, shown once) is a small addition.
- **There is no mobile app in this repository.** Tier 3 is a complete API with
no client yet. Everything above is what that app will call.
- **The admin cannot reset a merchant owner's password over HTTP**, nor suspend
@@ -443,6 +471,46 @@ deactivate them (§4); that revokes every session they hold.
"last_login_at": "…", "created_at": "…" }]
```
### `POST /api/team/members` — manager or owner
Create a login directly and hand it over. The alternative to an invitation
(§2) for somebody without a phone in hand.
```json
{ "email": "priya@tenext.in", "full_name": "Priya R", "role": "staff",
"password": "" }
```
```json
{ "id": "…", "email": "priya@tenext.in", "full_name": "Priya R",
"role": "staff", "active": true, "last_login_at": "", "created_at": "…",
"password": "m4kq…" }
```
- **`password` is returned once** and is not recoverable. Leave it empty in
the request and one is generated; supply one and it must be 8+ characters.
- `role` is `staff` (default), `manager` or `owner`. Only an owner may create
an owner; `admin` is refused.
- **409 `conflict`** if that email already has an account anywhere.
### `POST /api/team/{id}/password` — manager or owner
```json
{ } or { "password": "chosen-one" }
```
```json
{ "password": "n7xw…" }
```
Sets a new password (generated unless given) and **revokes every session the
member holds**, in one transaction. Returns the new password once. A member of
another company is **404**, never 403.
There is deliberately no self-service reset and no reset-by-email: a shop-floor
account often has no mailbox anyone checks, and the person who can vouch for
the salesperson standing in front of them is their manager.
### `PATCH /api/team/{id}` — manager or owner
```json