Let an admin create till staff from the console, through the same code
An admin sets a shop up from a browser; a supervisor adds a cashier at the counter. Both had to be possible, and only the second one was. So the console gets createposuser / updateposuser / getposusers / deleteposuser, under both /v1/web/tenants and /v1/mob/tenants — calling the same service methods `/pos/users` calls. Not a parallel implementation: a supervisor created from a browser is the same row, with the same PIN rules, the same duplicate check and the same identity-column allocation, as one created at a till. Two paths writing one table is precisely how the two stop matching, and this codebase already had that happen once. configid is inferred rather than asked for. It is a number nobody looks up, it varies per tenant — 1087's accounts are spread across 1, 6 and 15 — and getting it wrong creates somebody who cannot sign into the portal their colleagues use and is invisible to half the platform's queries. /posroles is served rather than left to the console to hardcode. A console that knew supervisor was 7 would be wrong the day that changed and would have no way to find out. The outlet is the real difference between the two doors. A terminal proves it with a signed token; the console asserts it, and is checked against the tenant before anything is written. That is weaker, and it is worth being plain about: these mint till credentials on an unauthenticated request, exactly like every other route in the /v1/web and /v1/mob groups, because there is no auth middleware on the web API at all. Documented as the weakest point in the design and flagged to move behind a session guard once the console can hold one. The terminal routes are untouched by it. Proven in a rolled-back transaction against live data: the console creates a supervisor at 1135, that supervisor signs in by PIN with can_manage_staff true, the till's /pos/staff sees them alongside the two created at the counter, and 0451, 1234 and a duplicate PIN are each refused with the same message the terminal gives. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
56
POS_LOGIN.md
56
POS_LOGIN.md
@@ -325,6 +325,62 @@ with one tap.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## Creating staff from the web console
|
||||||
|
|
||||||
|
The same staff management, for the screen an admin actually uses. Registered
|
||||||
|
under both `/v1/web/tenants` and `/v1/mob/tenants`.
|
||||||
|
|
||||||
|
```
|
||||||
|
GET /v1/web/tenants/posroles
|
||||||
|
GET /v1/web/tenants/getposusers?tenantid=1087&locationid=1135
|
||||||
|
POST /v1/web/tenants/createposuser
|
||||||
|
PUT /v1/web/tenants/updateposuser
|
||||||
|
DELETE /v1/web/tenants/deleteposuser?tenantid=1087&locationid=1135&userid=9189
|
||||||
|
```
|
||||||
|
|
||||||
|
`createposuser` takes the same body as `/pos/users`, plus the outlet — the
|
||||||
|
console has no session token, so it has to name one:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"tenantid": 1087,
|
||||||
|
"locationid": 1135,
|
||||||
|
"full_name": "Asha Kumar",
|
||||||
|
"role": "cashier",
|
||||||
|
"pin": "4821"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**These run the same service calls as `/pos/users`.** A supervisor created from
|
||||||
|
a browser is the same row, with the same rules applied, as one created at a
|
||||||
|
counter — same PIN validation, same duplicate check, same identity-column
|
||||||
|
allocation. That is the point of them: two paths writing one table is how the
|
||||||
|
two stop matching.
|
||||||
|
|
||||||
|
`configid` is never asked for. It is inferred from whichever value the tenant's
|
||||||
|
existing accounts carry — a number nobody looks up, that varies per tenant (1087
|
||||||
|
is spread across 1, 6 and 15), and that silently creates an account nobody can
|
||||||
|
find if it is wrong.
|
||||||
|
|
||||||
|
`GET /posroles` returns the two roles with their ids and labels, so a console
|
||||||
|
offering the choice never has to know that supervisor is `7`.
|
||||||
|
|
||||||
|
### :red_circle: These are unauthenticated
|
||||||
|
|
||||||
|
Like every other route in the `/v1/web` and `/v1/mob` groups — there is no auth
|
||||||
|
middleware anywhere on the web API. The outlet is checked against the tenant
|
||||||
|
before anything is written, so a caller cannot create staff at a shop that is
|
||||||
|
not theirs *given a tenant id* — but nothing proves the caller is that tenant.
|
||||||
|
|
||||||
|
So this mints till credentials on an unauthenticated request. It is consistent
|
||||||
|
with the rest of the platform, and it is still the weakest point in this design.
|
||||||
|
They should move behind a session guard as soon as the console can hold one.
|
||||||
|
|
||||||
|
The terminal routes are not affected: `/pos/users` proves its outlet with a
|
||||||
|
signed token.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Using the token
|
## Using the token
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -676,3 +676,154 @@ func posClaimError(c *fiber.Ctx, err error) error {
|
|||||||
}
|
}
|
||||||
return posServerError(c, "posClaims", err)
|
return posServerError(c, "posClaims", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------------------------------------------------------- Till staff, from the web
|
||||||
|
//
|
||||||
|
// The same staff management as `/pos/users`, for the console an admin actually
|
||||||
|
// uses. Deliberately the same service calls underneath rather than a parallel
|
||||||
|
// implementation: a supervisor created from a browser must be the same thing as
|
||||||
|
// one created at a counter, and two code paths writing one table is exactly how
|
||||||
|
// that stops being true.
|
||||||
|
//
|
||||||
|
// The difference is where the outlet comes from. A terminal proves it with a
|
||||||
|
// signed token; the console asserts it, because it has no session of its own.
|
||||||
|
// So it is verified against the tenant before anything is written — which is
|
||||||
|
// weaker than a signature, and is why these should move behind the same guard
|
||||||
|
// once the console can hold a session.
|
||||||
|
|
||||||
|
// posWebScope reads and checks the tenant and outlet a console request names.
|
||||||
|
func (ctl *PosController) posWebScope(tenantID, locationID int) error {
|
||||||
|
if tenantID <= 0 {
|
||||||
|
return fmt.Errorf("tenantid is required")
|
||||||
|
}
|
||||||
|
if locationID <= 0 {
|
||||||
|
return fmt.Errorf("locationid is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
allowed, err := ctl.posService.LocationAllowed(tenantID, locationID)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("could not verify the outlet")
|
||||||
|
}
|
||||||
|
if !allowed {
|
||||||
|
// Not "no such outlet" — that would confirm which ids exist. It did not
|
||||||
|
// belong to the tenant asking, and that is all the caller needs.
|
||||||
|
return fmt.Errorf("outlet %d does not belong to tenant %d", locationID, tenantID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebCreatePosUser adds a supervisor or cashier from the console.
|
||||||
|
func (ctl *PosController) WebCreatePosUser(c *fiber.Ctx) error {
|
||||||
|
var req models.PosUserWebRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The configid the outlet's other people already use, so a new cashier is
|
||||||
|
// visible to the same portal as their colleagues. Asked for rather than
|
||||||
|
// derived would mean a console sending a number nobody can look up.
|
||||||
|
configID := ctl.posService.ConfigidFor(req.Tenantid)
|
||||||
|
|
||||||
|
user, err := ctl.posService.CreateUser(req.Tenantid, req.Locationid, configID, req.PosUserRequest)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.Status(http.StatusCreated).JSON(fiber.Map{
|
||||||
|
"code": http.StatusCreated, "status": true,
|
||||||
|
"message": "User created", "details": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebUpdatePosUser edits one of an outlet's till users from the console.
|
||||||
|
func (ctl *PosController) WebUpdatePosUser(c *fiber.Ctx) error {
|
||||||
|
var req models.PosUserWebRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return posBadRequest(c, fmt.Errorf("invalid request body"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(req.Tenantid, req.Locationid); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
user, err := ctl.posService.UpdateUser(req.Tenantid, req.Locationid, req.PosUserRequest)
|
||||||
|
if err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"message": "User updated", "details": user,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebListPosUsers lists an outlet's till users for the console.
|
||||||
|
func (ctl *PosController) WebListPosUsers(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
users, err := ctl.posService.ListUsers(tenantID, locationID,
|
||||||
|
strings.EqualFold(c.Query("include_inactive"), "true"))
|
||||||
|
if err != nil {
|
||||||
|
return posServerError(c, "WebListPosUsers", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": fiber.Map{"location_id": locationID, "users": users},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebDeletePosUser retires a till user from the console.
|
||||||
|
func (ctl *PosController) WebDeletePosUser(c *fiber.Ctx) error {
|
||||||
|
tenantID, _ := strconv.Atoi(strings.TrimSpace(c.Query("tenantid")))
|
||||||
|
locationID, _ := strconv.Atoi(strings.TrimSpace(c.Query("locationid")))
|
||||||
|
|
||||||
|
if err := ctl.posWebScope(tenantID, locationID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
userID, err := strconv.Atoi(strings.TrimSpace(c.Query("userid")))
|
||||||
|
if err != nil || userID <= 0 {
|
||||||
|
return posBadRequest(c, fmt.Errorf("userid is required"))
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ctl.posService.DeactivateUser(tenantID, locationID, userID); err != nil {
|
||||||
|
return posBadRequest(c, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true, "message": "User deactivated",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// WebPosRoles lists the roles a console may offer.
|
||||||
|
//
|
||||||
|
// Served rather than hardcoded in the console, because the numbers are this
|
||||||
|
// backend's business. A console that hardcoded 7 and 8 would be wrong the day
|
||||||
|
// they change, and would have no way to know.
|
||||||
|
func (ctl *PosController) WebPosRoles(c *fiber.Ctx) error {
|
||||||
|
return c.JSON(fiber.Map{
|
||||||
|
"code": http.StatusOK, "status": true,
|
||||||
|
"details": []fiber.Map{
|
||||||
|
{
|
||||||
|
"role_id": models.PosRoleSupervisor, "role": "supervisor",
|
||||||
|
"label": models.PosRoleName(models.PosRoleSupervisor),
|
||||||
|
"description": "Runs the terminal and creates counter staff. Also signs into the app.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"role_id": models.PosRoleCashier, "role": "cashier",
|
||||||
|
"label": models.PosRoleName(models.PosRoleCashier),
|
||||||
|
"description": "Billing only.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -105,6 +105,8 @@ func (f *fakePosService) LoginWithPin(int, int, string) (*models.PosSession, err
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (f *fakePosService) ConfigidFor(int) int { return 0 }
|
||||||
|
|
||||||
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
func (f *fakePosService) LocationHealth(context.Context, string) ([]map[string]string, error) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -442,3 +442,18 @@ type PosUserRequest struct {
|
|||||||
Contactno string `json:"contactno"`
|
Contactno string `json:"contactno"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PosUserWebRequest is a staff change made from the web console.
|
||||||
|
//
|
||||||
|
// Identical to [PosUserRequest] but for the two fields a terminal never needs
|
||||||
|
// to send: the console has no session token, so it has to name the outlet it is
|
||||||
|
// working on. That is the one real difference between the two doors into this,
|
||||||
|
// and it is also the weaker one — the till's outlet is proved by a signature,
|
||||||
|
// while this is asserted. The handler checks the outlet belongs to the tenant
|
||||||
|
// before writing anything, which is as far as it can go without the console
|
||||||
|
// holding a session of its own.
|
||||||
|
type PosUserWebRequest struct {
|
||||||
|
PosUserRequest
|
||||||
|
Tenantid int `json:"tenantid"`
|
||||||
|
Locationid int `json:"locationid"`
|
||||||
|
}
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ type PosRepository interface {
|
|||||||
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
|
ListPosUsers(tenantID, locationID int, includeInactive bool) ([]models.PosUser, error)
|
||||||
DeactivatePosUser(tenantID, locationID, userID int) error
|
DeactivatePosUser(tenantID, locationID, userID int) error
|
||||||
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
PosLoginByPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
||||||
|
PosConfigidFor(tenantID int) int
|
||||||
|
|
||||||
// Reading counter sales back out. Without these a committed bill is
|
// Reading counter sales back out. Without these a committed bill is
|
||||||
// unreachable from every screen in the product.
|
// unreachable from every screen in the product.
|
||||||
|
|||||||
@@ -489,3 +489,22 @@ func (r *posRepository) StaffPinAvailable(tenantID, locationID int, pin int64, e
|
|||||||
taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser)
|
taken, err := posPinTaken(r.db, tenantID, locationID, pin, exceptUser)
|
||||||
return !taken, err
|
return !taken, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PosConfigidFor returns the configid an outlet's people already use.
|
||||||
|
//
|
||||||
|
// The console cannot sensibly be asked for this. It is a number nobody looks
|
||||||
|
// up, it varies per tenant — live data has tenant 1087 spread across 1, 6 and
|
||||||
|
// 15 — and getting it wrong creates an account that cannot sign into the portal
|
||||||
|
// its colleagues use and is invisible to half the platform's queries.
|
||||||
|
//
|
||||||
|
// So it is inferred from whichever value that tenant's existing accounts most
|
||||||
|
// commonly carry. Returns 0 for a tenant with no accounts at all, which is
|
||||||
|
// simply what a fresh tenant looks like.
|
||||||
|
func (r *posRepository) PosConfigidFor(tenantID int) int {
|
||||||
|
var configID int
|
||||||
|
r.db.Raw(`SELECT COALESCE(configid, 0) FROM app_users
|
||||||
|
WHERE tenantid = ? AND COALESCE(configid, 0) > 0
|
||||||
|
GROUP BY configid ORDER BY COUNT(*) DESC, configid LIMIT 1`,
|
||||||
|
tenantID).Scan(&configID)
|
||||||
|
return configID
|
||||||
|
}
|
||||||
|
|||||||
@@ -73,4 +73,37 @@ func RegisterPosRoutes(api fiber.Router, f *facade.Facade) {
|
|||||||
// broker rather than posting it here.
|
// broker rather than posting it here.
|
||||||
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
pos.Get("/health/terminal", f.PosController.TerminalHealth)
|
||||||
pos.Get("/health/location", f.PosController.LocationHealth)
|
pos.Get("/health/location", f.PosController.LocationHealth)
|
||||||
|
|
||||||
|
registerPosStaffConsoleRoutes(api, f)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Till staff, managed from the web console rather than from a counter.
|
||||||
|
//
|
||||||
|
// Under `/web` and `/mob` rather than `/pos`, because the callers are the back
|
||||||
|
// office and the daily app — neither holds a terminal session, and putting them
|
||||||
|
// behind the terminal guard would lock out the very screen an admin uses to set
|
||||||
|
// a shop up in the first place.
|
||||||
|
//
|
||||||
|
// They run the same service calls as `/pos/users`. A supervisor created here is
|
||||||
|
// the same row, with the same rules applied, as one created at a till.
|
||||||
|
//
|
||||||
|
// The outlet is asserted rather than proved, which is the real difference and
|
||||||
|
// the weaker half: a terminal signs its outlet, a console just names one. It is
|
||||||
|
// checked against the tenant before anything is written, and these should move
|
||||||
|
// behind a session guard as soon as the console can hold one — until then, this
|
||||||
|
// mints till credentials on the strength of an unauthenticated request, exactly
|
||||||
|
// like every other route in this group.
|
||||||
|
func registerPosStaffConsoleRoutes(api fiber.Router, f *facade.Facade) {
|
||||||
|
for _, group := range []string{"/v1/web/tenants", "/v1/mob/tenants"} {
|
||||||
|
g := api.Group(group)
|
||||||
|
|
||||||
|
// Served rather than hardcoded, so a console offering the choice does
|
||||||
|
// not have to know that supervisor is 7.
|
||||||
|
g.Get("/posroles", f.PosController.WebPosRoles)
|
||||||
|
|
||||||
|
g.Get("/getposusers", f.PosController.WebListPosUsers)
|
||||||
|
g.Post("/createposuser", f.PosController.WebCreatePosUser)
|
||||||
|
g.Put("/updateposuser", f.PosController.WebUpdatePosUser)
|
||||||
|
g.Delete("/deleteposuser", f.PosController.WebDeletePosUser)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,10 @@ type PosService interface {
|
|||||||
// LoginWithPin signs a person in at a terminal that is already open. Never
|
// LoginWithPin signs a person in at a terminal that is already open. Never
|
||||||
// reachable anonymously — four digits is not a barrier on its own.
|
// reachable anonymously — four digits is not a barrier on its own.
|
||||||
LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
LoginWithPin(tenantID, locationID int, pin string) (*models.PosSession, error)
|
||||||
|
|
||||||
|
// ConfigidFor infers which portal a tenant's people belong to, so the console
|
||||||
|
// is never asked for a number nobody can look up.
|
||||||
|
ConfigidFor(tenantID int) int
|
||||||
}
|
}
|
||||||
|
|
||||||
type posService struct {
|
type posService struct {
|
||||||
@@ -168,3 +172,7 @@ func (s *posService) LoginWithPin(tenantID, locationID int, pin string) (*models
|
|||||||
}
|
}
|
||||||
return s.mint(session, "")
|
return s.mint(session, "")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *posService) ConfigidFor(tenantID int) int {
|
||||||
|
return s.repo.PosConfigidFor(tenantID)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user