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")
|
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
|
||||||
@@ -1884,10 +1882,12 @@ func CreateMiler(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
user := models.AppUser{
|
user := models.AppUser{
|
||||||
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,
|
||||||
|
|||||||
@@ -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,49 +117,57 @@ func VerifyMilerPin(cfg *config.Config) fiber.Handler {
|
|||||||
return utils.Unauthorized(c, "incorrect PIN")
|
return utils.Unauthorized(c, "incorrect PIN")
|
||||||
}
|
}
|
||||||
|
|
||||||
token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret)
|
return issueMilerSession(c, cfg, user, req.DeviceToken)
|
||||||
if err != nil {
|
}
|
||||||
return utils.Internal(c, "failed to generate token")
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var profile models.MilerProfile
|
// issueMilerSession builds the authenticated-session response shared by
|
||||||
if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil {
|
// verify-pin and set-pin: it mints the JWT, ensures a MilerProfile exists,
|
||||||
profile = models.MilerProfile{
|
// refreshes the device token, and returns the single {token, user, profile}
|
||||||
Userid: user.Userid,
|
// shape both entry points must agree on. tenantname drives the app's
|
||||||
Displayname: user.Authname,
|
// service-profile resolution (hyperlocal vs logistics), checked ahead of the
|
||||||
Phone: user.Contactno,
|
// raw tenantid and persisted client-side so a rider picks up a changed tenant
|
||||||
Availabilitystatus: constants.MilerOffline,
|
// name on next login.
|
||||||
Rating: 5.0,
|
func issueMilerSession(c *fiber.Ctx, cfg *config.Config, user models.AppUser, deviceToken string) error {
|
||||||
Applocationid: user.Applocationid,
|
token, err := utils.GenerateToken(user.Userid, user.Email, user.Roleid, user.Tenantid, user.Configid, cfg.JWTSecret)
|
||||||
}
|
if err != nil {
|
||||||
db.DB.Create(&profile)
|
return utils.Internal(c, "failed to generate token")
|
||||||
}
|
}
|
||||||
if req.DeviceToken != "" && profile.Devicetoken != req.DeviceToken {
|
|
||||||
profile.Devicetoken = req.DeviceToken
|
|
||||||
db.DB.Model(&profile).Update("device_token", req.DeviceToken)
|
|
||||||
}
|
|
||||||
|
|
||||||
// tenantname drives the app's service-profile resolution (hyperlocal vs
|
var profile models.MilerProfile
|
||||||
// logistics), checked ahead of the raw tenantid. Persisted client-side at
|
if err := db.DB.Where("userid = ?", user.Userid).First(&profile).Error; err != nil {
|
||||||
// verify-pin, so a rider picks up a changed tenant name on next login.
|
profile = models.MilerProfile{
|
||||||
tenantName := resolveTenantName(user.Tenantid)
|
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{
|
tenantName := resolveTenantName(user.Tenantid)
|
||||||
"success": true,
|
|
||||||
"token": token,
|
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,
|
"tenantid": user.Tenantid,
|
||||||
"tenantname": tenantName,
|
"tenantname": tenantName,
|
||||||
"user": fiber.Map{
|
"profile": profile,
|
||||||
"userid": user.Userid,
|
},
|
||||||
"authname": user.Authname,
|
})
|
||||||
"email": user.Email,
|
|
||||||
"contactno": user.Contactno,
|
|
||||||
"tenantid": user.Tenantid,
|
|
||||||
"tenantname": tenantName,
|
|
||||||
"profile": profile,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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
|
||||||
@@ -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 {
|
||||||
|
|||||||
12
dto/admin.go
12
dto/admin.go
@@ -63,10 +63,14 @@ type VehicleCreateRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type MilerCreateRequest struct {
|
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)
|
||||||
|
|||||||
10
dto/auth.go
10
dto/auth.go
@@ -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"`
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user