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:
@@ -258,15 +258,44 @@ login, it is simply not found.
|
|||||||
one, 22 live accounts have it including a delivery rider, and it grants nothing
|
one, 22 live accounts have it including a delivery rider, and it grants nothing
|
||||||
on either side.
|
on either side.
|
||||||
|
|
||||||
### A Supervisor needs a password, a Cashier does not
|
### Every till account gets its own username and password
|
||||||
|
|
||||||
A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a session that
|
Both roles. A PIN cannot open a *closed* terminal — `/pos/login/pin` requires a
|
||||||
already exists. So a Supervisor is provisioned with an `authname` **and** a
|
session that already exists — so a PIN-only account works only while somebody
|
||||||
`password` as well as a PIN, and a Cashier gets only a PIN: a cashier signs on
|
else is standing there to unlock the till first. For a Supervisor that was an
|
||||||
at a counter a Supervisor has already opened, so a second password would be one
|
outright deadlock; for a Cashier it meant a shop that could not open until two
|
||||||
more credential to leak for no capability gained.
|
people had arrived, and whoever gets in at seven is as often the cashier as the
|
||||||
|
supervisor.
|
||||||
|
|
||||||
Provisioning a Cashier and nobody else leaves an outlet with no way in at all.
|
So a Cashier signs in exactly like a Supervisor does, and the *role* decides
|
||||||
|
what they get — not which credential they used:
|
||||||
|
|
||||||
|
```
|
||||||
|
POST /v1/pos/login supervisor.1185@pos.nearle.in -> full shell
|
||||||
|
POST /v1/pos/login cashier.1185@pos.nearle.in -> billing only
|
||||||
|
```
|
||||||
|
|
||||||
|
`POST /pos/users` generates both when the request omits them, and returns the
|
||||||
|
password **once**, in the creation response only:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "user_id": 1452, "role": "Cashier",
|
||||||
|
"authname": "cashier.1185@pos.nearle.in",
|
||||||
|
"password": "9tWx2KUJksM5Rm", "pin": "4513", "has_password": true }
|
||||||
|
```
|
||||||
|
|
||||||
|
`GET /pos/users` never returns a password, only `has_password`. An admin who
|
||||||
|
loses it reissues rather than looks it up.
|
||||||
|
|
||||||
|
Send `authname` and `password` explicitly if the shop wants its people signing
|
||||||
|
in as themselves. A generated name that collides — a second cashier at one
|
||||||
|
outlet — becomes `cashier2.1185@pos.nearle.in`; a name **you** supplied is never
|
||||||
|
adjusted, it is refused, because silently signing somebody in as another
|
||||||
|
person's address is worse than an error.
|
||||||
|
|
||||||
|
The PIN stays optional. It switches operator at an open counter, which not every
|
||||||
|
shop does, and it is the one credential the till holds in plaintext to hand
|
||||||
|
around — so it is set deliberately, never by default.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -321,11 +350,16 @@ own store id.
|
|||||||
|---|---|
|
|---|---|
|
||||||
| `full_name` | required; split across `firstname`/`lastname` |
|
| `full_name` | required; split across `firstname`/`lastname` |
|
||||||
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
| `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted |
|
||||||
| `pin` | 4 digits. See the rules below |
|
| `pin` | optional, 4 digits. See the rules below |
|
||||||
| `password` + `authname` | optional; for someone who also signs the terminal in |
|
| `authname` | optional. **Generated if omitted** — `cashier.1185@pos.nearle.in`, or `cashier2.…` if that is taken |
|
||||||
|
| `password` | optional. **Generated if omitted**, and returned once in this response |
|
||||||
|
|
||||||
**At least one of `pin` or `password` is required.** Creating a person who can
|
**Everyone gets a username and a password, cashiers included**, because a PIN
|
||||||
sign in by neither would look like it worked right up until somebody tried.
|
cannot open a closed terminal. Omit both fields and they are generated for you,
|
||||||
|
so provisioning a shop is one call per person.
|
||||||
|
|
||||||
|
The response is the only time the password is returned; `GET /pos/users` reports
|
||||||
|
`has_password` and nothing more.
|
||||||
|
|
||||||
:warning: **PIN rules, and why**
|
:warning: **PIN rules, and why**
|
||||||
|
|
||||||
|
|||||||
@@ -438,6 +438,11 @@ type PosUser struct {
|
|||||||
Role string `json:"role"`
|
Role string `json:"role"`
|
||||||
Pin string `json:"pin,omitempty"`
|
Pin string `json:"pin,omitempty"`
|
||||||
Haspassword bool `json:"has_password"`
|
Haspassword bool `json:"has_password"`
|
||||||
|
|
||||||
|
// The password, returned only in the answer to a creation or a reset and
|
||||||
|
// never by a listing. An admin who loses it reissues rather than looks it
|
||||||
|
// up — the right shape even while the column behind it is plaintext.
|
||||||
|
Password string `json:"password,omitempty"`
|
||||||
Locationid int `json:"location_id"`
|
Locationid int `json:"location_id"`
|
||||||
Status string `json:"status"`
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
package repositories
|
package repositories
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"math/big"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
@@ -32,6 +34,51 @@ const (
|
|||||||
PosPinMax = 9999
|
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.
|
// 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) {
|
func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req models.PosUserRequest) (*models.PosUser, error) {
|
||||||
roleID := models.PosRoleFromName(req.Role)
|
roleID := models.PosRoleFromName(req.Role)
|
||||||
@@ -53,15 +100,31 @@ func (r *posRepository) CreatePosUser(tenantID, locationID, configID int, req mo
|
|||||||
password := strings.TrimSpace(req.Password)
|
password := strings.TrimSpace(req.Password)
|
||||||
authname := strings.ToLower(strings.TrimSpace(req.Authname))
|
authname := strings.ToLower(strings.TrimSpace(req.Authname))
|
||||||
|
|
||||||
// One or the other, at least. A person with neither cannot sign in, and
|
// Every till account gets a username and a password, cashiers included.
|
||||||
// creating them would look like it worked right up until somebody tried.
|
//
|
||||||
if pin == 0 && password == "" {
|
// A PIN cannot open a *closed* terminal — the PIN route needs a session that
|
||||||
return nil, fmt.Errorf("set a PIN, a password, or both — otherwise this person cannot sign in")
|
// 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 == "" {
|
if password == "" {
|
||||||
return nil, fmt.Errorf("a password needs an email to go with it")
|
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
|
var created *models.PosUser
|
||||||
|
|
||||||
err = r.db.Transaction(func(tx *gorm.DB) error {
|
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 != "" {
|
// Uniqueness is checked against `authname` and `email` together because
|
||||||
var clash int64
|
// the insert below writes the same value to both, and
|
||||||
if err := tx.Raw(`SELECT COUNT(1) FROM app_users WHERE LOWER(TRIM(authname)) = ?`,
|
// `app_users_email_unique` is a real constraint — a clash there fails the
|
||||||
authname).Scan(&clash).Error; err != nil {
|
// 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
|
return err
|
||||||
}
|
}
|
||||||
if clash > 0 {
|
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 {
|
||||||
return fmt.Errorf("an account already uses %s", authname)
|
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 != "",
|
Haspassword: password != "",
|
||||||
Locationid: locationID,
|
Locationid: locationID,
|
||||||
Status: "Active",
|
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
|
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 ?")
|
queryBuilder.WriteString(" ORDER BY a.userid DESC LIMIT ? OFFSET ?")
|
||||||
params = append(params, pagesize, offset)
|
params = append(params, pagesize, offset)
|
||||||
|
|
||||||
print(queryBuilder.String())
|
|
||||||
|
|
||||||
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
|
if err := r.db.Raw(queryBuilder.String(), params...).Scan(&users).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,34 @@ func main() {
|
|||||||
fmt.Sprintf("role=%s can_manage_staff=%v", session.Role, session.Canmanagestaff))
|
fmt.Sprintf("role=%s can_manage_staff=%v", session.Role, session.Canmanagestaff))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The same, for a cashier. A cashier opening a till on their own credentials
|
||||||
|
// is the point of this: a shop should not need two people present before it
|
||||||
|
// can sell anything.
|
||||||
|
var cash struct {
|
||||||
|
Userid int
|
||||||
|
Authname, Password string
|
||||||
|
}
|
||||||
|
db.Raw(`SELECT userid, COALESCE(authname,'') authname, COALESCE(password,'') password
|
||||||
|
FROM app_users
|
||||||
|
WHERE COALESCE(roleid,0) = ? AND COALESCE(authname,'') <> ''
|
||||||
|
ORDER BY userid LIMIT 1`, models.PosRoleCashier).Scan(&cash)
|
||||||
|
|
||||||
|
if cash.Userid == 0 {
|
||||||
|
check("a cashier has their own login", false, "no cashier has an authname")
|
||||||
|
} else {
|
||||||
|
cs, err := pos.PosLogin(models.PosLoginRequest{
|
||||||
|
Authname: cash.Authname, Password: cash.Password,
|
||||||
|
})
|
||||||
|
check(fmt.Sprintf("cashier %s opens a closed terminal alone", cash.Authname),
|
||||||
|
err == nil && cs != nil,
|
||||||
|
fmt.Sprintf("err=%v", err))
|
||||||
|
if cs != nil {
|
||||||
|
check("and is held to the billing-only shell",
|
||||||
|
!cs.Canmanagestaff && cs.Roleid == models.PosRoleCashier,
|
||||||
|
fmt.Sprintf("role=%s can_manage_staff=%v", cs.Role, cs.Canmanagestaff))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fmt.Println("\n2. back-office accounts cannot open a terminal at all")
|
fmt.Println("\n2. back-office accounts cannot open a terminal at all")
|
||||||
var backOffice []struct {
|
var backOffice []struct {
|
||||||
Userid int
|
Userid int
|
||||||
|
|||||||
@@ -29,24 +29,6 @@ import (
|
|||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// newPassword generates a password for a supervisor's till login.
|
|
||||||
//
|
|
||||||
// From crypto/rand and printed once, like the PINs. Deliberately not derived
|
|
||||||
// from the shop's name or id: a credential anybody could guess from the sign
|
|
||||||
// above the door is not a credential.
|
|
||||||
func newPassword() 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 {
|
|
||||||
log.Fatalf("generating a password: %v", err)
|
|
||||||
}
|
|
||||||
out[i] = alphabet[n.Int64()]
|
|
||||||
}
|
|
||||||
return string(out)
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
mode, tenantID, locationID := "plan", 1087, 1135
|
mode, tenantID, locationID := "plan", 1087, 1135
|
||||||
if len(os.Args) > 1 {
|
if len(os.Args) > 1 {
|
||||||
@@ -96,29 +78,17 @@ func main() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// The supervisor gets a username and password as well as a PIN, because a
|
// Both roles get a username and a password as well as a PIN, and neither is
|
||||||
// PIN cannot open a *closed* terminal — the PIN route requires a session
|
// stated here: CreatePosUser generates them and returns them once.
|
||||||
// that already exists. Without these, an outlet whose only accounts are POS
|
|
||||||
// accounts has no way in at all: the back-office logins are refused by role
|
|
||||||
// and the till logins have no password. That deadlock is not hypothetical;
|
|
||||||
// it is what the first cut of strict mode actually produced.
|
|
||||||
//
|
//
|
||||||
// The cashier deliberately gets neither. They sign on at a terminal a
|
// A PIN cannot open a *closed* terminal — the PIN route requires a session
|
||||||
// supervisor has already opened, so a second password would be one more
|
// that already exists — so a PIN-only account works only while somebody else
|
||||||
// credential to leak for no capability gained.
|
// is standing there to unlock the till first. For a supervisor that was an
|
||||||
//
|
// outright deadlock; for a cashier it means a shop that cannot open until
|
||||||
// The username is derived from the outlet rather than from a person, so it
|
// two people have arrived. Whoever gets in at seven is as often the cashier
|
||||||
// survives staff turnover. `authname` is not unique in this schema, but
|
// as the supervisor.
|
||||||
// scoping it to the outlet keeps it unambiguous in practice, and `email` is
|
|
||||||
// left null on purpose — that column *is* unique, and blank strings collide.
|
|
||||||
wanted := []models.PosUserRequest{
|
wanted := []models.PosUserRequest{
|
||||||
{
|
{Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()},
|
||||||
Fullname: "Store Supervisor",
|
|
||||||
Role: "supervisor",
|
|
||||||
Pin: newPin(),
|
|
||||||
Authname: fmt.Sprintf("supervisor.%d@pos.nearle.in", locationID),
|
|
||||||
Password: newPassword(),
|
|
||||||
},
|
|
||||||
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
|
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
|
||||||
}
|
}
|
||||||
for wanted[0].Pin == wanted[1].Pin {
|
for wanted[0].Pin == wanted[1].Pin {
|
||||||
@@ -127,11 +97,8 @@ func main() {
|
|||||||
|
|
||||||
fmt.Println("\nwould create:")
|
fmt.Println("\nwould create:")
|
||||||
for _, w := range wanted {
|
for _, w := range wanted {
|
||||||
fmt.Printf(" %-22s %-12s pin=%s", w.Fullname, w.Role, w.Pin)
|
fmt.Printf(" %-22s %-12s pin=%s (login generated on create)\n",
|
||||||
if w.Authname != "" {
|
w.Fullname, w.Role, w.Pin)
|
||||||
fmt.Printf(" login=%s / %s", w.Authname, w.Password)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if mode != "apply" {
|
if mode != "apply" {
|
||||||
@@ -145,12 +112,9 @@ func main() {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("creating %s: %v", w.Fullname, err)
|
log.Fatalf("creating %s: %v", w.Fullname, err)
|
||||||
}
|
}
|
||||||
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s",
|
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
|
||||||
created.Userid, created.Fullname, created.Role, created.Pin)
|
created.Userid, created.Fullname, created.Role, created.Pin)
|
||||||
if w.Authname != "" {
|
fmt.Printf(" login %s / %s\n", created.Authname, created.Password)
|
||||||
fmt.Printf(" login=%s / %s", w.Authname, w.Password)
|
|
||||||
}
|
|
||||||
fmt.Println()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// The point of the exercise: does the till now see real staff?
|
// The point of the exercise: does the till now see real staff?
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
// Gives every provisioned supervisor a way to open a closed terminal.
|
// Gives every till account a way to open a closed terminal.
|
||||||
//
|
//
|
||||||
// A PIN cannot do it: the PIN route requires a session that already exists, so
|
// A PIN cannot do it: the PIN route requires a session that already exists, so
|
||||||
// an outlet whose only POS accounts are PIN-only has no way in once back-office
|
// a PIN-only account works only while somebody else is standing there to unlock
|
||||||
// roles are refused. This backfills the username and password for supervisors
|
// the till first. For a supervisor that was an outright deadlock. For a cashier
|
||||||
// created before that was understood.
|
// it means a shop that cannot open until two people have arrived — and whoever
|
||||||
|
// gets in at seven is as often the cashier as the supervisor.
|
||||||
//
|
//
|
||||||
// Cashiers are deliberately skipped. They sign on at a terminal a supervisor
|
// So both roles get a username and a password. This backfills the ones created
|
||||||
// has already opened, so a password would be one more credential to leak for no
|
// before that was understood; new accounts get them from CreatePosUser.
|
||||||
// capability gained.
|
|
||||||
//
|
//
|
||||||
// go run ./scratch/possupervisorlogin plan
|
// go run ./scratch/postilllogin plan
|
||||||
// go run ./scratch/possupervisorlogin apply
|
// go run ./scratch/postilllogin apply
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -20,6 +20,8 @@ import (
|
|||||||
"math/big"
|
"math/big"
|
||||||
"os"
|
"os"
|
||||||
|
|
||||||
|
"strings"
|
||||||
|
|
||||||
"nearle/models"
|
"nearle/models"
|
||||||
"nearle/repositories"
|
"nearle/repositories"
|
||||||
|
|
||||||
@@ -62,30 +64,31 @@ func main() {
|
|||||||
repo := repositories.NewPosRepository(db)
|
repo := repositories.NewPosRepository(db)
|
||||||
|
|
||||||
type target struct {
|
type target struct {
|
||||||
Userid, Tenantid, Locationid int
|
Userid, Tenantid, Locationid, Roleid int
|
||||||
Fullname, Locationname string
|
Fullname, Locationname string
|
||||||
}
|
}
|
||||||
var targets []target
|
var targets []target
|
||||||
db.Raw(`SELECT a.userid, a.tenantid, a.locationid,
|
db.Raw(`SELECT a.userid, a.tenantid, a.locationid, COALESCE(a.roleid,0) AS roleid,
|
||||||
TRIM(COALESCE(a.firstname,'')||' '||COALESCE(a.lastname,'')) AS fullname,
|
TRIM(COALESCE(a.firstname,'')||' '||COALESCE(a.lastname,'')) AS fullname,
|
||||||
COALESCE(l.locationname,'') AS locationname
|
COALESCE(l.locationname,'') AS locationname
|
||||||
FROM app_users a
|
FROM app_users a
|
||||||
LEFT JOIN tenantlocations l
|
LEFT JOIN tenantlocations l
|
||||||
ON l.locationid = a.locationid AND l.tenantid = a.tenantid
|
ON l.locationid = a.locationid AND l.tenantid = a.tenantid
|
||||||
WHERE COALESCE(a.roleid,0) = ?
|
WHERE COALESCE(a.roleid,0) IN (?, ?)
|
||||||
AND (COALESCE(a.password,'') = '' OR COALESCE(a.authname,'') = '')
|
AND (COALESCE(a.password,'') = '' OR COALESCE(a.authname,'') = '')
|
||||||
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
|
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
|
||||||
ORDER BY a.userid`, models.PosRoleSupervisor).Scan(&targets)
|
ORDER BY a.userid`, models.PosRoleSupervisor, models.PosRoleCashier).Scan(&targets)
|
||||||
|
|
||||||
if len(targets) == 0 {
|
if len(targets) == 0 {
|
||||||
fmt.Println("Every supervisor already has a till login. Nothing to do.")
|
fmt.Println("Every till account already has a login. Nothing to do.")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
fmt.Printf("supervisors with no way to open a closed terminal: %d\n\n", len(targets))
|
fmt.Printf("till accounts with no way to open a closed terminal: %d\n\n", len(targets))
|
||||||
|
|
||||||
for _, t := range targets {
|
for _, t := range targets {
|
||||||
authname := fmt.Sprintf("supervisor.%d@pos.nearle.in", t.Locationid)
|
authname := fmt.Sprintf("%s.%d@pos.nearle.in",
|
||||||
|
strings.ToLower(models.PosRoleName(t.Roleid)), t.Locationid)
|
||||||
password := newPassword()
|
password := newPassword()
|
||||||
|
|
||||||
if mode != "apply" {
|
if mode != "apply" {
|
||||||
Reference in New Issue
Block a user