Files
backend_fiesta/scratch/postilllogin/main.go
Suriya 9e9401215d 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>
2026-08-07 12:04:24 +05:30

132 lines
4.1 KiB
Go

// 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-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.
//
// 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/postilllogin plan
// go run ./scratch/postilllogin apply
package main
import (
"crypto/rand"
"fmt"
"log"
"math/big"
"os"
"strings"
"nearle/models"
"nearle/repositories"
"github.com/joho/godotenv"
"gorm.io/driver/postgres"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func newPassword() string {
// No l/I/O/0/1 — these get read off a screen and typed at a counter.
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 := "plan"
if len(os.Args) > 1 {
mode = os.Args[1]
}
_ = 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)
type target struct {
Userid, Tenantid, Locationid, Roleid int
Fullname, Locationname string
}
var targets []target
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) IN (?, ?)
AND (COALESCE(a.password,'') = '' OR COALESCE(a.authname,'') = '')
AND LOWER(COALESCE(a.status,'active')) <> 'inactive'
ORDER BY a.userid`, models.PosRoleSupervisor, models.PosRoleCashier).Scan(&targets)
if len(targets) == 0 {
fmt.Println("Every till account already has a login. Nothing to do.")
return
}
fmt.Printf("till accounts with no way to open a closed terminal: %d\n\n", len(targets))
for _, t := range targets {
authname := fmt.Sprintf("%s.%d@pos.nearle.in",
strings.ToLower(models.PosRoleName(t.Roleid)), t.Locationid)
password := newPassword()
if mode != "apply" {
fmt.Printf(" %-6d %-18s outlet %-6d %-26s -> %s / %s\n",
t.Userid, t.Fullname, t.Locationid, t.Locationname, authname, password)
continue
}
_, err := repo.UpdatePosUser(t.Tenantid, t.Locationid, models.PosUserRequest{
Userid: t.Userid,
Authname: authname,
Password: password,
})
if err != nil {
fmt.Printf(" %-6d FAILED: %v\n", t.Userid, err)
continue
}
// Prove it, rather than assert it — the whole point of this tool is that
// a supervisor who cannot sign in is indistinguishable from one who can
// until somebody stands at a counter and tries.
session, err := repo.PosLogin(models.PosLoginRequest{
Authname: authname,
Password: password,
})
if err != nil {
fmt.Printf(" %-6d written, but sign-in still fails: %v\n", t.Userid, err)
continue
}
fmt.Printf(" %-6d %-18s outlet %-6d %-26s\n", t.Userid, t.Fullname, t.Locationid, t.Locationname)
fmt.Printf(" login %s / %s\n", authname, password)
fmt.Printf(" opens as %s at %s, can_manage_staff=%v\n",
session.Role, session.Locationname, session.Canmanagestaff)
}
if mode != "apply" {
fmt.Println("\nNothing written — run `apply` to commit.")
}
}