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>
172 lines
5.5 KiB
Go
172 lines
5.5 KiB
Go
// Creates a supervisor and a cashier at an outlet, then proves both can sign in.
|
||
//
|
||
// Exists because outlet 1135 — the one the terminal ships pointed at — had no
|
||
// staff at all, so the till fell back to the three PINs compiled into the app.
|
||
// Real staff here are what retire those.
|
||
//
|
||
// PINs are generated rather than chosen, from crypto/rand, and printed once so
|
||
// they can be handed to the shop. They are deliberately not derived from
|
||
// anything guessable.
|
||
//
|
||
// go run ./scratch/posstaffsetup plan 1087 1135
|
||
// go run ./scratch/posstaffsetup apply 1087 1135
|
||
package main
|
||
|
||
import (
|
||
"crypto/rand"
|
||
"fmt"
|
||
"log"
|
||
"math/big"
|
||
"os"
|
||
"strconv"
|
||
|
||
"nearle/models"
|
||
"nearle/repositories"
|
||
|
||
"github.com/joho/godotenv"
|
||
"gorm.io/driver/postgres"
|
||
"gorm.io/gorm"
|
||
"gorm.io/gorm/logger"
|
||
)
|
||
|
||
func main() {
|
||
mode, tenantID, locationID := "plan", 1087, 1135
|
||
if len(os.Args) > 1 {
|
||
mode = os.Args[1]
|
||
}
|
||
if len(os.Args) > 3 {
|
||
tenantID, _ = strconv.Atoi(os.Args[2])
|
||
locationID, _ = strconv.Atoi(os.Args[3])
|
||
}
|
||
|
||
_ = godotenv.Load()
|
||
dsn := fmt.Sprintf("host=%s port=%s user=%s password=%s dbname=%s sslmode=disable",
|
||
os.Getenv("DB_HOST"), os.Getenv("DB_PORT"), os.Getenv("DB_USER"),
|
||
os.Getenv("DB_PASSWORD"), os.Getenv("DB_NAME"))
|
||
db, err := gorm.Open(postgres.Open(dsn), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
repo := repositories.NewPosRepository(db)
|
||
|
||
var locName string
|
||
db.Raw(`SELECT COALESCE(locationname,'') FROM tenantlocations WHERE locationid=? AND tenantid=?`,
|
||
locationID, tenantID).Scan(&locName)
|
||
if locName == "" {
|
||
log.Fatalf("tenant %d has no outlet %d", tenantID, locationID)
|
||
}
|
||
|
||
// The configid the shop's other accounts use, so a new cashier is visible
|
||
// to the same portal as everybody else at that outlet.
|
||
var configID int
|
||
db.Raw(`SELECT COALESCE(configid,0) FROM app_users
|
||
WHERE tenantid=? AND COALESCE(configid,0) > 0
|
||
GROUP BY configid ORDER BY COUNT(*) DESC LIMIT 1`, tenantID).Scan(&configID)
|
||
|
||
fmt.Printf("tenant %d, outlet %d (%s), configid %d\n\n", tenantID, locationID, locName, configID)
|
||
|
||
existing, err := repo.ListPosUsers(tenantID, locationID, true)
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
fmt.Printf("till users already at this outlet: %d\n", len(existing))
|
||
for _, u := range existing {
|
||
fmt.Printf(" %-6d %-22s %-12s pin=%s %s\n", u.Userid, u.Fullname, u.Role, u.Pin, u.Status)
|
||
}
|
||
if len(existing) > 0 {
|
||
fmt.Println("\nAlready set up. Nothing to do — this refuses to add duplicates.")
|
||
return
|
||
}
|
||
|
||
// 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.
|
||
//
|
||
// 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()},
|
||
{Fullname: "Counter Cashier", Role: "cashier", Pin: newPin()},
|
||
}
|
||
for wanted[0].Pin == wanted[1].Pin {
|
||
wanted[1].Pin = newPin()
|
||
}
|
||
|
||
fmt.Println("\nwould create:")
|
||
for _, w := range wanted {
|
||
fmt.Printf(" %-22s %-12s pin=%s (login generated on create)\n",
|
||
w.Fullname, w.Role, w.Pin)
|
||
}
|
||
|
||
if mode != "apply" {
|
||
fmt.Println("\nNothing written — run `apply` to commit.")
|
||
return
|
||
}
|
||
|
||
fmt.Println()
|
||
for _, w := range wanted {
|
||
created, err := repo.CreatePosUser(tenantID, locationID, configID, w)
|
||
if err != nil {
|
||
log.Fatalf("creating %s: %v", w.Fullname, err)
|
||
}
|
||
fmt.Printf(" created userid %-6d %-22s %-12s PIN %s\n",
|
||
created.Userid, created.Fullname, created.Role, created.Pin)
|
||
fmt.Printf(" login %s / %s\n", created.Authname, created.Password)
|
||
}
|
||
|
||
// The point of the exercise: does the till now see real staff?
|
||
staff, err := repo.PosStaff(tenantID, locationID)
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
fmt.Printf("\n/pos/staff now returns %d person(s):\n", len(staff))
|
||
for _, s := range staff {
|
||
fmt.Printf(" %-22s %-12s\n", s.Fullname, s.Role)
|
||
}
|
||
|
||
// And can they actually sign in?
|
||
fmt.Println("\nPIN sign-in:")
|
||
for _, w := range wanted {
|
||
session, err := repo.PosLoginByPin(tenantID, locationID, w.Pin)
|
||
if err != nil {
|
||
fmt.Printf(" %-22s REFUSED: %v\n", w.Fullname, err)
|
||
continue
|
||
}
|
||
fmt.Printf(" %-22s -> %s at %s, can_manage_staff=%v\n",
|
||
w.Fullname, session.Role, session.Locationname, session.Canmanagestaff)
|
||
}
|
||
|
||
if _, err := repo.PosLoginByPin(tenantID, locationID, "5555"); err != nil {
|
||
fmt.Printf("\n an unknown PIN is refused: %v\n", err)
|
||
} else {
|
||
fmt.Println("\n !! an unknown PIN was ACCEPTED")
|
||
}
|
||
|
||
fmt.Println("\n-- undo:")
|
||
fmt.Printf("UPDATE app_users SET status='InActive' WHERE tenantid=%d AND locationid=%d AND roleid IN (%d,%d);\n",
|
||
tenantID, locationID, models.PosRoleSupervisor, models.PosRoleCashier)
|
||
}
|
||
|
||
// newPin returns a four-digit PIN this schema can store, from crypto/rand.
|
||
//
|
||
// 1000–9999 because a leading zero cannot survive a bigint column, and the
|
||
// obvious ones are rejected by validatePosPin anyway — retried here rather than
|
||
// filtered, so the distribution stays even.
|
||
func newPin() string {
|
||
for {
|
||
n, err := rand.Int(rand.Reader, big.NewInt(9000))
|
||
if err != nil {
|
||
log.Fatal(err)
|
||
}
|
||
pin := strconv.FormatInt(n.Int64()+1000, 10)
|
||
switch pin {
|
||
case "1234", "1111", "2345", "3456", "4321", "9999", "2222":
|
||
continue
|
||
}
|
||
return pin
|
||
}
|
||
}
|