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:
@@ -676,3 +676,154 @@ func posClaimError(c *fiber.Ctx, err error) error {
|
||||
}
|
||||
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.",
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user