feat: miler self-set PIN on first login; no console-set default PIN

Milers now choose their own PIN the first time they log in, instead of the
console assigning a shared default:

- CreateMiler always creates a rider with an empty Password (PIN field removed
  from MilerCreateRequest); any client-supplied PIN is ignored, making
  "riders set their own PIN" a backend invariant, not a console convention.
- LoginMiler returns `pin_set` so the app routes to enter-PIN vs set-PIN.
- New POST /miler/set-pin (SetMilerPin): self-service first PIN, allowed ONLY
  when the account has none yet (409 otherwise, so it can't overwrite/take over
  an active account), then logs the rider in. Self-service and throttle-only is
  safe because of that guard; OTP-gate it once the SMS gateway is live.
- verify-pin and set-pin share issueMilerSession so the two success responses
  can't drift.

Also switches BookingPickupComplete's timestamp to DBNow() (IST) so the
compatibility-flow inwardedat matches the reconciliation windows.

Existing riders keep their PIN and are unaffected; blanking their password to
move them onto self-set is a separate, deliberate DB step.

go build, go vet and go test ./... all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WRaFH5hMRqmUQvVPQsyjZD
This commit is contained in:
2026-09-16 12:17:00 +05:30
parent ba2cd2299c
commit dd0fa75e7b
5 changed files with 133 additions and 49 deletions

View File

@@ -1859,8 +1859,6 @@ func CreateMiler(c *fiber.Ctx) error {
return utils.BadRequest(c, "invalid request body") return utils.BadRequest(c, "invalid request body")
} }
passHash, _ := utils.HashPassword(req.Password)
tx := db.DB.Begin() tx := db.DB.Begin()
appLocID := req.Applocationid appLocID := req.Applocationid
@@ -1887,7 +1885,9 @@ func CreateMiler(c *fiber.Ctx) error {
Authname: req.Authname, Authname: req.Authname,
Email: req.Email, Email: req.Email,
Contactno: req.Contactno, Contactno: req.Contactno,
Password: passHash, // Empty PIN by design: the rider self-sets it on first login via
// /miler/set-pin. See MilerCreateRequest — no console-set PIN.
Password: "",
Roleid: 5, // Miler Roleid: 5, // Miler
Status: "Active", Status: "Active",
Applocationid: appLocID, Applocationid: appLocID,

View File

@@ -55,10 +55,14 @@ func LoginMiler(cfg *config.Config) fiber.Handler {
return utils.Forbidden(c, "miler account is not active") return utils.Forbidden(c, "miler account is not active")
} }
// pin_set tells the app which screen to show next: true → enter-PIN
// (verify-pin login), false → set-PIN (first-time). A rider created without
// a PIN has an empty Password; every real PIN is a non-empty bcrypt hash.
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
"success": true, "success": true,
"message": "PIN verification required", "message": "PIN verification required",
"phone": req.Phone, "phone": req.Phone,
"pin_set": user.Password != "",
}) })
} }
} }
@@ -113,6 +117,18 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
return utils.Unauthorized(c, "incorrect PIN") return utils.Unauthorized(c, "incorrect PIN")
} }
return issueMilerSession(c, cfg, user, req.DeviceToken)
}
}
// issueMilerSession builds the authenticated-session response shared by
// verify-pin and set-pin: it mints the JWT, ensures a MilerProfile exists,
// refreshes the device token, and returns the single {token, user, profile}
// shape both entry points must agree on. tenantname drives the app's
// service-profile resolution (hyperlocal vs logistics), checked ahead of the
// raw tenantid and persisted client-side so a rider picks up a changed tenant
// name on next login.
func issueMilerSession(c *fiber.Ctx, cfg *config.Config, user models.AppUser, deviceToken string) error {
token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret) token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret)
if err != nil { if err != nil {
return utils.Internal(c, "failed to generate token") return utils.Internal(c, "failed to generate token")
@@ -130,14 +146,11 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
} }
db.DB.Create(&profile) db.DB.Create(&profile)
} }
if req.DeviceToken != "" && profile.Devicetoken != req.DeviceToken { if deviceToken != "" && profile.Devicetoken != deviceToken {
profile.Devicetoken = req.DeviceToken profile.Devicetoken = deviceToken
db.DB.Model(&profile).Update("device_token", req.DeviceToken) db.DB.Model(&profile).Update("device_token", deviceToken)
} }
// tenantname drives the app's service-profile resolution (hyperlocal vs
// logistics), checked ahead of the raw tenantid. Persisted client-side at
// verify-pin, so a rider picks up a changed tenant name on next login.
tenantName := resolveTenantName(user.Tenantid) tenantName := resolveTenantName(user.Tenantid)
return c.JSON(fiber.Map{ return c.JSON(fiber.Map{
@@ -156,7 +169,6 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
}, },
}) })
} }
}
// ResetMilerPin lets a miler who forgot their PIN set a new one from just // ResetMilerPin lets a miler who forgot their PIN set a new one from just
// their phone number, matching ResetCustomerPin's flow exactly (protected // their phone number, matching ResetCustomerPin's flow exactly (protected
@@ -200,6 +212,57 @@ func ResetMilerPin(c *fiber.Ctx) error {
return utils.Message(c, "PIN reset successfully") return utils.Message(c, "PIN reset successfully")
} }
// SetMilerPin is the self-service first-login PIN creation, the counterpart to
// the ops-only ResetMilerPin. It is allowed ONLY when the account has no PIN yet
// (empty Password): because it can never overwrite an existing PIN, the
// phone-only unauthenticated path here cannot take over an already-active
// account the way an unguarded reset could — the worst case is a not-yet-used
// account being claimed by someone who knows the phone number, which OTP should
// close once the SMS gateway is live. A rider who already has a PIN is sent to
// verify-pin (or an ops reset) via 409. On success the rider is logged in
// immediately, so the app never has to make a second verify-pin call.
func SetMilerPin(cfg *config.Config) fiber.Handler {
return func(c *fiber.Ctx) error {
req := new(dto.MilerSetPinRequest)
if err := c.BodyParser(req); err != nil {
return utils.BadRequest(c, "invalid request body")
}
if req.Phone == "" || req.NewPin == "" {
return utils.BadRequest(c, "phone and new_pin are required")
}
configID := req.Configid
if configID == 0 {
configID = 1001
}
var user models.AppUser
if err := db.DB.Where("contactno = ? AND configid = ?", req.Phone, configID).First(&user).Error; err != nil {
return utils.NotFound(c, "no miler account found for this phone number")
}
if user.Roleid != 5 {
return utils.Forbidden(c, "this endpoint is restricted to miler accounts")
}
if user.Status != "Active" {
return utils.Forbidden(c, "miler account is not active")
}
if user.Password != "" {
return utils.Conflict(c, "a PIN is already set for this account; use verify-pin to log in")
}
pinHash, err := utils.HashPassword(req.NewPin)
if err != nil {
return utils.Internal(c, "failed to set PIN")
}
user.Password = pinHash
if err := db.DB.Save(&user).Error; err != nil {
return utils.Internal(c, "failed to set PIN")
}
return issueMilerSession(c, cfg, user, req.DeviceToken)
}
}
func GetMilerProfile(c *fiber.Ctx) error { func GetMilerProfile(c *fiber.Ctx) error {
milerUserID := c.Locals("userid").(int) milerUserID := c.Locals("userid").(int)
@@ -1119,7 +1182,9 @@ func BookingPickupComplete(c *fiber.Ctx) error {
return utils.NotFound(c, "assigned booking not found") return utils.NotFound(c, "assigned booking not found")
} }
now := time.Now() // IST wall-clock (DBNow), so the compatibility-flow inwardedat stamped below
// (and booking.updatedat) matches createdat and the reconciliation windows.
now := utils.DBNow()
booking.Status = constants.BookingPickedUp booking.Status = constants.BookingPickedUp
booking.Updatedat = now booking.Updatedat = now
if err := tx.Save(&booking).Error; err != nil { if err := tx.Save(&booking).Error; err != nil {

View File

@@ -66,7 +66,11 @@ type MilerCreateRequest struct {
Authname string `json:"authname"` Authname string `json:"authname"`
Email string `json:"email"` Email string `json:"email"`
Contactno string `json:"contactno"` Contactno string `json:"contactno"`
Password string `json:"password"` // No PIN field: riders never receive a console-set PIN. A rider is created
// with an empty Password and sets their own PIN on first login via
// /miler/set-pin (LoginMiler reports pin_set:false). Any "password" the
// console sends is ignored. This makes "riders choose their own PIN" a
// backend invariant instead of trusting the console not to send a default.
Displayname string `json:"displayname"` Displayname string `json:"displayname"`
// Tenantid attaches a rider to the client they deliver for — riders migrated // Tenantid attaches a rider to the client they deliver for — riders migrated
// from jupiter belong to a specific client (DailyGrubs, Bawa Medicals) // from jupiter belong to a specific client (DailyGrubs, Bawa Medicals)

View File

@@ -24,6 +24,16 @@ type MilerResetPinRequest struct {
Configid int `json:"configid"` Configid int `json:"configid"`
} }
// MilerSetPinRequest is the self-service first-login PIN creation payload
// (SetMilerPin). DeviceToken is accepted so the rider is fully logged in the
// moment they set their PIN, without a second verify-pin round trip.
type MilerSetPinRequest struct {
Phone string `json:"phone" xml:"phone" form:"phone"`
NewPin string `json:"new_pin" xml:"new_pin" form:"new_pin"`
Configid int `json:"configid"`
DeviceToken string `json:"device_token"`
}
type AdminLoginRequest struct { type AdminLoginRequest struct {
Email string `json:"email" xml:"email" form:"email"` Email string `json:"email" xml:"email" form:"email"`
Password string `json:"password" xml:"password" form:"password"` Password string `json:"password" xml:"password" form:"password"`

View File

@@ -175,6 +175,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
miler := api.Group("/miler") miler := api.Group("/miler")
miler.Post("/login", authThrottle, controllers.LoginMiler(cfg)) miler.Post("/login", authThrottle, controllers.LoginMiler(cfg))
miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg)) miler.Post("/verify-pin", authThrottle, controllers.VerifyMilerPin(cfg))
// First-login PIN creation is self-service (unlike reset-pin below), and safe
// to leave unauthenticated because SetMilerPin refuses to overwrite an
// existing PIN — it only works on an account that has none yet. authThrottle
// still caps abuse of the phone-number probe.
miler.Post("/set-pin", authThrottle, controllers.SetMilerPin(cfg))
// PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites // PIN reset is console-operated, NOT self-service: ResetMilerPin overwrites
// the PIN given only a phone number, and phone numbers are the miler login // the PIN given only a phone number, and phone numbers are the miler login
// identifier rather than a secret. Left unauthenticated, two calls // identifier rather than a secret. Left unauthenticated, two calls