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:
@@ -1859,8 +1859,6 @@ func CreateMiler(c *fiber.Ctx) error {
|
||||
return utils.BadRequest(c, "invalid request body")
|
||||
}
|
||||
|
||||
passHash, _ := utils.HashPassword(req.Password)
|
||||
|
||||
tx := db.DB.Begin()
|
||||
|
||||
appLocID := req.Applocationid
|
||||
@@ -1884,10 +1882,12 @@ func CreateMiler(c *fiber.Ctx) error {
|
||||
}
|
||||
|
||||
user := models.AppUser{
|
||||
Authname: req.Authname,
|
||||
Email: req.Email,
|
||||
Contactno: req.Contactno,
|
||||
Password: passHash,
|
||||
Authname: req.Authname,
|
||||
Email: req.Email,
|
||||
Contactno: req.Contactno,
|
||||
// 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
|
||||
Status: "Active",
|
||||
Applocationid: appLocID,
|
||||
|
||||
@@ -55,10 +55,14 @@ func LoginMiler(cfg *config.Config) fiber.Handler {
|
||||
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{
|
||||
"success": true,
|
||||
"message": "PIN verification required",
|
||||
"phone": req.Phone,
|
||||
"pin_set": user.Password != "",
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -113,49 +117,57 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
|
||||
return utils.Unauthorized(c, "incorrect PIN")
|
||||
}
|
||||
|
||||
token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to generate token")
|
||||
}
|
||||
return issueMilerSession(c, cfg, user, req.DeviceToken)
|
||||
}
|
||||
}
|
||||
|
||||
var profile models.MilerProfile
|
||||
if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil {
|
||||
profile = models.MilerProfile{
|
||||
Userid: user.Userid,
|
||||
Displayname: user.Authname,
|
||||
Phone: user.Contactno,
|
||||
Availabilitystatus: constants.MilerOffline,
|
||||
Rating: 5.0,
|
||||
Applocationid: user.Applocationid,
|
||||
}
|
||||
db.DB.Create(&profile)
|
||||
}
|
||||
if req.DeviceToken != "" && profile.Devicetoken != req.DeviceToken {
|
||||
profile.Devicetoken = req.DeviceToken
|
||||
db.DB.Model(&profile).Update("device_token", 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)
|
||||
if err != nil {
|
||||
return utils.Internal(c, "failed to generate token")
|
||||
}
|
||||
|
||||
// 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)
|
||||
var profile models.MilerProfile
|
||||
if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil {
|
||||
profile = models.MilerProfile{
|
||||
Userid: user.Userid,
|
||||
Displayname: user.Authname,
|
||||
Phone: user.Contactno,
|
||||
Availabilitystatus: constants.MilerOffline,
|
||||
Rating: 5.0,
|
||||
Applocationid: user.Applocationid,
|
||||
}
|
||||
db.DB.Create(&profile)
|
||||
}
|
||||
if deviceToken != "" && profile.Devicetoken != deviceToken {
|
||||
profile.Devicetoken = deviceToken
|
||||
db.DB.Model(&profile).Update("device_token", deviceToken)
|
||||
}
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"token": token,
|
||||
tenantName := resolveTenantName(user.Tenantid)
|
||||
|
||||
return c.JSON(fiber.Map{
|
||||
"success": true,
|
||||
"token": token,
|
||||
"tenantid": user.Tenantid,
|
||||
"tenantname": tenantName,
|
||||
"user": fiber.Map{
|
||||
"userid": user.Userid,
|
||||
"authname": user.Authname,
|
||||
"email": user.Email,
|
||||
"contactno": user.Contactno,
|
||||
"tenantid": user.Tenantid,
|
||||
"tenantname": tenantName,
|
||||
"user": fiber.Map{
|
||||
"userid": user.Userid,
|
||||
"authname": user.Authname,
|
||||
"email": user.Email,
|
||||
"contactno": user.Contactno,
|
||||
"tenantid": user.Tenantid,
|
||||
"tenantname": tenantName,
|
||||
"profile": profile,
|
||||
},
|
||||
})
|
||||
}
|
||||
"profile": profile,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// ResetMilerPin lets a miler who forgot their PIN set a new one from just
|
||||
@@ -200,6 +212,57 @@ func ResetMilerPin(c *fiber.Ctx) error {
|
||||
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 {
|
||||
milerUserID := c.Locals("userid").(int)
|
||||
|
||||
@@ -1119,7 +1182,9 @@ func BookingPickupComplete(c *fiber.Ctx) error {
|
||||
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.Updatedat = now
|
||||
if err := tx.Save(&booking).Error; err != nil {
|
||||
|
||||
12
dto/admin.go
12
dto/admin.go
@@ -63,10 +63,14 @@ type VehicleCreateRequest struct {
|
||||
}
|
||||
|
||||
type MilerCreateRequest struct {
|
||||
Authname string `json:"authname"`
|
||||
Email string `json:"email"`
|
||||
Contactno string `json:"contactno"`
|
||||
Password string `json:"password"`
|
||||
Authname string `json:"authname"`
|
||||
Email string `json:"email"`
|
||||
Contactno string `json:"contactno"`
|
||||
// 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"`
|
||||
// Tenantid attaches a rider to the client they deliver for — riders migrated
|
||||
// from jupiter belong to a specific client (DailyGrubs, Bawa Medicals)
|
||||
|
||||
10
dto/auth.go
10
dto/auth.go
@@ -24,6 +24,16 @@ type MilerResetPinRequest struct {
|
||||
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 {
|
||||
Email string `json:"email" xml:"email" form:"email"`
|
||||
Password string `json:"password" xml:"password" form:"password"`
|
||||
|
||||
@@ -175,6 +175,11 @@ func RegisterRoutes(app *fiber.App, cfg *config.Config) {
|
||||
miler := api.Group("/miler")
|
||||
miler.Post("/login", authThrottle, controllers.LoginMiler(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
|
||||
// the PIN given only a phone number, and phone numbers are the miler login
|
||||
// identifier rather than a secret. Left unauthenticated, two calls
|
||||
|
||||
Reference in New Issue
Block a user