Give a cashier their own till login, not just a PIN behind a supervisor
A PIN cannot open a closed terminal. The PIN route needs a session that already exists, so a PIN-only account works only while somebody else is standing there to unlock the till first. For a supervisor that was an outright deadlock and was fixed last commit. For a cashier it is subtler and just as wrong: the shop cannot open until two people have arrived, and whoever gets in at seven is as often the cashier as the supervisor. So every till account now gets a username and a password, and the role decides the shell rather than the credential deciding it. A cashier signs in exactly the way a supervisor does and is still held to billing only, because that comes from roleid 8 and not from how they got in. The earlier reasoning — that a second password is one more credential to leak for no capability gained — was measuring the wrong thing. It counted the cost of the credential and not the cost of the shop that cannot open without one. CreatePosUser generates both when the request omits them, so provisioning is one call per person and nobody has to invent a naming scheme. An explicit value always wins. A generated name that collides walks to the next free one, because a second cashier at one counter is ordinary rather than an error; a name the caller supplied is refused instead, because silently signing somebody in as another person's address is worse than a message. Uniqueness is checked against authname and email together, since the insert writes the same value to both and app_users_email_unique would otherwise fail the transaction rather than return something anyone can act on. The password comes back exactly once, in the creation response. Listing till users still reports only has_password, so an admin who loses it reissues rather than looks it up — the right shape even while the column behind it is plaintext. The domain is deliberately unroutable. These are till credentials, never a mailbox, and an address that looks deliverable invites somebody to try sending a reset to it. Verified against live rows by scratch/posseparation, which now checks the cashier path too: cashier.1185@pos.nearle.in opens a closed terminal alone and comes back can_manage_staff=false. All five outlets that stock products have both accounts, each proved by an actual sign-in. Also drops a stray `print(queryBuilder.String())` from GetAllUsers, which was writing the whole SQL statement to stderr on every call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
package repositories
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"fmt"
|
||||
"math/big"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
@@ -32,6 +34,51 @@ const (
|
||||
PosPinMax = 9999
|
||||
)
|
||||
|
||||
// posDefaultAuthname is the username a till account gets when nobody names one.
|
||||
//
|
||||
// Keyed on the outlet and the role rather than on the person, so it survives
|
||||
// staff turnover: a shop replacing its cashier reissues one password instead of
|
||||
// re-teaching a new address. `nth` disambiguates a second account of the same
|
||||
// role at the same counter and is omitted for the first, so the common case
|
||||
// stays the readable one.
|
||||
//
|
||||
// The domain is deliberately not a real one. These are till credentials, never
|
||||
// a mailbox, and an address that looks deliverable invites somebody to try
|
||||
// sending a reset to it.
|
||||
func posDefaultAuthname(roleID, locationID, nth int) string {
|
||||
role := strings.ToLower(models.PosRoleName(roleID))
|
||||
if role == "" {
|
||||
role = "staff"
|
||||
}
|
||||
if nth > 1 {
|
||||
return fmt.Sprintf("%s%d.%d@pos.nearle.in", role, nth, locationID)
|
||||
}
|
||||
return fmt.Sprintf("%s.%d@pos.nearle.in", role, locationID)
|
||||
}
|
||||
|
||||
// newPosPassword generates a till password.
|
||||
//
|
||||
// From crypto/rand, and returned to the caller exactly once — at creation —
|
||||
// because the column it lands in is plaintext and reading it back later should
|
||||
// take a deliberate query rather than an ordinary list call.
|
||||
//
|
||||
// The alphabet drops l, I, O, 0 and 1. These get read off one screen and typed
|
||||
// on another by somebody with a queue in front of them.
|
||||
func newPosPassword() string {
|
||||
const alphabet = "abcdefghijkmnopqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
|
||||
out := make([]byte, 14)
|
||||
for i := range out {
|
||||
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(alphabet))))
|
||||
if err != nil {
|
||||
// crypto/rand failing is not a condition to paper over with a
|
||||
// weaker source; a guessable till password is worse than no till.
|
||||
panic(fmt.Sprintf("generating a till password: %v", err))
|
||||
}
|
||||
out[i] = alphabet[n.Int64()]
|
||||
}
|
||||
return string(out)
|
||||
}
|
||||
|
||||
// CreatePosUser adds a cashier or supervisor at the caller's outlet.
|
||||
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
|
||||
roleID := models.PosRoleFromName(req.Role)
|
||||
@@ -53,15 +100,31 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
password := strings.TrimSpace(req.Password)
|
||||
authname := strings.ToLower(strings.TrimSpace(req.Authname))
|
||||
|
||||
// One or the other, at least. A person with neither cannot sign in, and
|
||||
// creating them would look like it worked right up until somebody tried.
|
||||
if pin == 0 && password == "" {
|
||||
return nil, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
|
||||
// Every till account gets a username and a password, cashiers included.
|
||||
//
|
||||
// A PIN cannot open a *closed* terminal — the PIN route needs a session that
|
||||
// already exists — so a PIN-only cashier can work only while a supervisor is
|
||||
// standing there to unlock the till first. That is not how a shop opens: the
|
||||
// person who arrives at seven is as often the cashier as the supervisor.
|
||||
//
|
||||
// Generated when the console does not supply them, so provisioning is one
|
||||
// call and nobody has to invent a scheme. An explicit value always wins: a
|
||||
// shop that wants its people signing in as themselves just sends one.
|
||||
//
|
||||
// Whether the name was generated is remembered, because the two cases want
|
||||
// opposite handling on a collision — see the uniqueness check below.
|
||||
nameWasGenerated := authname == ""
|
||||
if nameWasGenerated {
|
||||
authname = posDefaultAuthname(roleID, locationID, 0)
|
||||
}
|
||||
if password != "" && authname == "" {
|
||||
return nil, fmt.Errorf("a password needs an email to go with it")
|
||||
if password == "" {
|
||||
password = newPosPassword()
|
||||
}
|
||||
|
||||
// A PIN stays optional. It switches operator at an open counter, which not
|
||||
// every shop does, and it is the one credential the till keeps in plaintext
|
||||
// to hand around — so it is set deliberately, never by default.
|
||||
|
||||
var created *models.PosUser
|
||||
|
||||
err = r.db.Transaction(func(tx *gorm.DB) error {
|
||||
@@ -91,13 +154,44 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
}
|
||||
}
|
||||
|
||||
if authname != "" {
|
||||
var clash int64
|
||||
if err := tx.Raw(`SELECT COUNT(1) FROM app_users WHERE LOWER(TRIM(authname)) = ?`,
|
||||
authname).Scan(&clash).Error; err != nil {
|
||||
// Uniqueness is checked against `authname` and `email` together because
|
||||
// the insert below writes the same value to both, and
|
||||
// `app_users_email_unique` is a real constraint — a clash there fails the
|
||||
// transaction rather than returning a message anyone can act on.
|
||||
taken := func(candidate string) (bool, error) {
|
||||
var n int64
|
||||
err := tx.Raw(`SELECT COUNT(1) FROM app_users
|
||||
WHERE LOWER(TRIM(authname)) = ? OR LOWER(TRIM(email)) = ?`,
|
||||
candidate, candidate).Scan(&n).Error
|
||||
return n > 0, err
|
||||
}
|
||||
|
||||
if nameWasGenerated {
|
||||
// Walk to the first free one. Bounded so a bug here cannot spin:
|
||||
// twenty till accounts of one role at a single outlet is already far
|
||||
// past what a counter has, and the error names the fix.
|
||||
found := false
|
||||
for i := 0; i < 20; i++ {
|
||||
clash, err := taken(authname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !clash {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
authname = posDefaultAuthname(roleID, locationID, i+2)
|
||||
}
|
||||
if !found {
|
||||
return fmt.Errorf("this outlet already has too many %s accounts; supply an email explicitly",
|
||||
strings.ToLower(models.PosRoleName(roleID)))
|
||||
}
|
||||
} else {
|
||||
clash, err := taken(authname)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if clash > 0 {
|
||||
if clash {
|
||||
return fmt.Errorf("an account already uses %s", authname)
|
||||
}
|
||||
}
|
||||
@@ -140,6 +234,12 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
||||
Haspassword: password != "",
|
||||
Locationid: locationID,
|
||||
Status: "Active",
|
||||
|
||||
// The one moment this is ever returned. Listing a till user reports
|
||||
// only whether a password exists, so an admin who loses this has to
|
||||
// reissue rather than look it up — which is the right shape even
|
||||
// while the column itself is plaintext.
|
||||
Password: password,
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
@@ -90,8 +90,6 @@ func (r *userRepository) GetAllUsers(roleID, tenantID, pageno, pagesize int, key
|
||||
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
|
||||
params = append(params, pagesize, offset)
|
||||
|
||||
print(queryBuilder.String())
|
||||
|
||||
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user