diff --git a/docs/POS_LOGIN.md b/docs/POS_LOGIN.md index e2829d4..9beef8d 100644 --- a/docs/POS_LOGIN.md +++ b/docs/POS_LOGIN.md @@ -258,15 +258,44 @@ login, it is simply not found. one, 22 live accounts have it including a delivery rider, and it grants nothing 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 -already exists. So a Supervisor is provisioned with an `authname` **and** a -`password` as well as a PIN, and a Cashier gets only a PIN: a cashier signs on -at a counter a Supervisor has already opened, so a second password would be one -more credential to leak for no capability gained. +Both roles. A PIN cannot open a *closed* terminal — `/pos/login/pin` requires 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; for a Cashier it meant a shop that could not open until two +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` | | `role` | `"supervisor"` or `"cashier"`. Anything else is refused — never defaulted | -| `pin` | 4 digits. See the rules below | -| `password` + `authname` | optional; for someone who also signs the terminal in | +| `pin` | optional, 4 digits. See the rules below | +| `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 -sign in by neither would look like it worked right up until somebody tried. +**Everyone gets a username and a password, cashiers included**, because a PIN +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** diff --git a/models/pos.go b/models/pos.go index 40aaa08..3693a72 100644 --- a/models/pos.go +++ b/models/pos.go @@ -438,6 +438,11 @@ type PosUser struct { Role string `json:"role"` Pin string `json:"pin,omitempty"` 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"` Status string `json:"status"` } diff --git a/repositories/posUserRepository.go b/repositories/posUserRepository.go index 1e475d8..dd2ac77 100644 --- a/repositories/posUserRepository.go +++ b/repositories/posUserRepository.go @@ -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 }) diff --git a/repositories/userRepository.go b/repositories/userRepository.go index e077d0c..952f297 100644 --- a/repositories/userRepository.go +++ b/repositories/userRepository.go @@ -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 } diff --git a/scratch/posseparation/main.go b/scratch/posseparation/main.go index a489805..6495f39 100644 --- a/scratch/posseparation/main.go +++ b/scratch/posseparation/main.go @@ -86,6 +86,34 @@ func main() { 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") var backOffice []struct { Userid int diff --git a/scratch/posstaffsetup/main.go b/scratch/posstaffsetup/main.go index c774278..f634448 100644 --- a/scratch/posstaffsetup/main.go +++ b/scratch/posstaffsetup/main.go @@ -29,24 +29,6 @@ import ( "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() { mode, tenantID, locationID := "plan", 1087, 1135 if len(os.Args) > 1 { @@ -96,29 +78,17 @@ func main() { return } - // The supervisor gets a username and password as well as a PIN, because a - // PIN cannot open a *closed* terminal — the PIN route requires a session - // 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. + // Both roles get a username and a password as well as a PIN, and neither is + // stated here: CreatePosUser generates them and returns them once. // - // The cashier deliberately gets neither. They sign on at a terminal a - // supervisor has already opened, so a second password would be one more - // credential to leak for no capability gained. - // - // The username is derived from the outlet rather than from a person, so it - // survives staff turnover. `authname` is not unique in this schema, but - // 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. + // A PIN cannot open a *closed* terminal — the PIN route requires 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; for a cashier it means a shop that cannot open until + // two people have arrived. Whoever gets in at seven is as often the cashier + // as the supervisor. wanted := []models.PosUserRequest{ - { - Fullname: "Store Supervisor", - Role: "supervisor", - Pin: newPin(), - Authname: fmt.Sprintf("supervisor.%d@pos.nearle.in", locationID), - Password: newPassword(), - }, + {Fullname: "Store Supervisor", Role: "supervisor", Pin: newPin()}, {Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()}, } for wanted[0].Pin == wanted[1].Pin { @@ -127,11 +97,8 @@ func main() { fmt.Println("\nwould create:") for _, w := range wanted { - fmt.Printf(" %-22s %-12s pin=%s", w.Fullname, w.Role, w.Pin) - if w.Authname != "" { - fmt.Printf(" login=%s / %s", w.Authname, w.Password) - } - fmt.Println() + fmt.Printf(" %-22s %-12s pin=%s (login generated on create)\n", + w.Fullname, w.Role, w.Pin) } if mode != "apply" { @@ -145,12 +112,9 @@ func main() { if err != nil { 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) - if w.Authname != "" { - fmt.Printf(" login=%s / %s", w.Authname, w.Password) - } - fmt.Println() + fmt.Printf(" login %s / %s\n", created.Authname, created.Password) } // The point of the exercise: does the till now see real staff? diff --git a/scratch/possupervisorlogin/main.go b/scratch/postilllogin/main.go similarity index 71% rename from scratch/possupervisorlogin/main.go rename to scratch/postilllogin/main.go index 9d1f214..f935ac2 100644 --- a/scratch/possupervisorlogin/main.go +++ b/scratch/postilllogin/main.go @@ -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 -// an outlet whose only POS accounts are PIN-only has no way in once back-office -// roles are refused. This backfills the username and password for supervisors -// created before that was understood. +// 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. For a cashier +// 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 -// has already opened, so a password would be one more credential to leak for no -// capability gained. +// So both roles get a username and a password. This backfills the ones created +// before that was understood; new accounts get them from CreatePosUser. // -// go run ./scratch/possupervisorlogin plan -// go run ./scratch/possupervisorlogin apply +// go run ./scratch/postilllogin plan +// go run ./scratch/postilllogin apply package main import ( @@ -20,6 +20,8 @@ import ( "math/big" "os" + "strings" + "nearle/models" "nearle/repositories" @@ -62,30 +64,31 @@ func main() { repo := repositories.NewPosRepository(db) type target struct { - Userid, Tenantid, Locationid int - Fullname, Locationname string + Userid, Tenantid, Locationid, Roleid int + Fullname, Locationname string } 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, COALESCE(l.locationname,'') AS locationname FROM app_users a LEFT JOIN tenantlocations l 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 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 { - 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 } - 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 { - 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() if mode != "apply" {