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:
Suriya
2026-08-07 12:04:24 +05:30
parent c0a7fbc1b1
commit 9e9401215d
7 changed files with 222 additions and 90 deletions

View File

@@ -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
})

View File

@@ -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
}