package controllers import ( "log" "net/http" "strconv" "strings" "time" "nearle/models" "nearle/services" "nearle/utils" "github.com/gofiber/fiber/v2" ) // attachWebSession hands a signed-in console user their session token. // // Added to the login response rather than served from a second endpoint, so the // console receives it on the call it already makes and nothing changes about // when or how it signs in. // // The claims come from the user's own record, which is the whole point: until // now the console asserted its tenant on every request and was believed, and // sealing it under a signature here is what makes `middleware.WebAuth` able to // refuse a request naming somebody else's. // // `Issuperadmin` is copied across as the ONLY source of cross-tenant access. // Not the role — `app_roles` calls roleid 1 "Super admin" and tenant onboarding // wrote 1 for every shop owner, so trusting the role would promote every // merchant on the platform. // // A failure to mint is logged and swallowed, deliberately, while // WEB_AUTH_REQUIRED is off: a deployment that has not set a signing key yet must // still be able to sign in, or shipping this takes the console down everywhere // the secret is missing. Once enforcement is on, no token means no session — // which is then the correct and loud failure. // // The parameter is the underlying map type rather than `fiber.Map`, because the // two login paths do not agree on which fiber that is: `AppLogin` returns the // v1 package's `Map` and `TenantWebLogin` the v2 one. Both are // `map[string]any`, so taking that accepts either without dragging the // old import into this file. func attachWebSession(resp map[string]any, info models.TenantUserInfo) { token, expires, err := utils.MintWebToken(utils.WebClaims{ Userid: info.Userid, Tenantid: info.Tenantid, Locationid: info.Locationid, Roleid: info.Roleid, Configid: info.Configid, Superadmin: info.Issuperadmin, }, time.Now()) if err != nil { log.Printf("login: could not issue a console session for user %d: %v", info.Userid, err) return } resp["token"] = token resp["tokenexpiresat"] = expires.Unix() } type UserController struct { userService services.UserService } func NewUserController(userService services.UserService) *UserController { return &UserController{userService: userService} } func (ctl *UserController) GetAllUsers(c *fiber.Ctx) error { roleID, _ := strconv.Atoi(c.Query("roleid", "0")) tenantID, _ := strconv.Atoi(c.Query("tenantid", "0")) pageno, _ := strconv.Atoi(c.Query("pageno", "1")) pagesize, _ := strconv.Atoi(c.Query("pagesize", "10")) keyword := c.Query("keyword", "") if tenantID == 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "tenantid is required", "status": false, }) } users, err := ctl.userService.GetAllUsers(roleID, tenantID, pageno, pagesize, keyword) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": err.Error(), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": users, }) } func (ctl *UserController) GetUserInfo(c *fiber.Ctx) error { uid, err := strconv.Atoi(c.Query("userid")) if err != nil || uid <= 0 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid userid", "status": false, }) } user, err := ctl.userService.GetUserByID(uid) if err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": err.Error(), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": user, }) } func (ctl *UserController) Login(c *fiber.Ctx) error { var user models.User if err := c.BodyParser(&user); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid request body", "status": false, }) } info, err := ctl.userService.Login(user) if err != nil { return c.Status(http.StatusConflict).JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": "User not found", }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": info, }) } func (ctl *UserController) TenantLogin(c *fiber.Ctx) error { var user models.User if err := c.BodyParser(&user); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": http.StatusBadRequest, "message": "Invalid request body", }) } info, err := ctl.userService.TenantLogin(user) if err != nil { return c.Status(http.StatusConflict).JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": err.Error(), }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "Success", "status": true, "details": info, }) } func (ctl *UserController) UpdateStaff(c *fiber.Ctx) error { var user models.User if err := c.BodyParser(&user); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": http.StatusBadRequest, "message": "Invalid request body", }) } // This endpoint also doubles as the password-setup/reset call (userid + // password only, everything else left zero so GORM's Updates skips it) — // guard the one field that has no validation anywhere else on this path. if pw := strings.TrimSpace(user.Password); pw != "" && len(pw) < 6 { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": http.StatusBadRequest, "message": "Password must be at least 6 characters", }) } if err := ctl.userService.UpdateStaff(user); err != nil { return c.JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": err.Error(), }) } return c.JSON(fiber.Map{ "status": true, "code": http.StatusAccepted, "message": "User update successful", }) } func (ctl *UserController) AppLogin(c *fiber.Ctx) error { var user models.User if err := c.BodyParser(&user); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "code": 400, "status": false, "message": "Invalid request body", }) } info, resp, err := ctl.userService.AppLogin(user) if err != nil { // Use resp.Code if present, fallback to 409 code := http.StatusConflict if v, ok := resp["code"].(int); ok { code = v } return c.Status(code).JSON(resp) } attachWebSession(resp, info) // ✅ Always return resp return c.Status(http.StatusOK).JSON(resp) } func (ctl *UserController) CreateUser(c *fiber.Ctx) error { var user models.User // Parse request body if err := c.BodyParser(&user); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "status": false, "message": "Invalid request body", }) } // Call service info, invite, err := ctl.userService.CreateUser(user) if err != nil { return c.Status(http.StatusConflict).JSON(fiber.Map{ "code": http.StatusConflict, "status": false, "message": "Failed", }) } // The account was created either way. Whether its first-password invitation // was emailed is reported beside it rather than folded into `status`: the // account has no password and the link is the only way to set one, so an // operator who is not told has hired somebody who cannot sign in. return c.Status(http.StatusCreated).JSON(fiber.Map{ "code": http.StatusCreated, "status": true, "message": "Success", "details": info, "invited": invite.Sent, "invitereason": invite.Reason, }) } func (ctl *UserController) TenantWebLogin(c *fiber.Ctx) error { var user models.User if err := c.BodyParser(&user); err != nil { return c.Status(fiber.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": fiber.StatusBadRequest, "message": "Invalid request body", }) } info, resp := ctl.userService.TenantWebLogin(user) // Ensure the response map contains the correct status code code, ok := resp["code"].(int) if !ok { code = fiber.StatusInternalServerError } // Include tenant user info if login successful (code 200) if code == fiber.StatusOK { resp["details"] = info attachWebSession(resp, info) } return c.Status(code).JSON(resp) } func (ctl *UserController) DeleteUser(c *fiber.Ctx) error { uid, err := strconv.Atoi(c.Query("userid")) if err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "code": http.StatusBadRequest, "message": "Invalid user ID", "status": false, }) } if err := ctl.userService.DeleteUser(uid); err != nil { return c.Status(http.StatusInternalServerError).JSON(fiber.Map{ "code": http.StatusInternalServerError, "message": err.Error(), "status": false, }) } return c.JSON(fiber.Map{ "code": http.StatusOK, "message": "User successfully deleted", "status": true, }) } // SetPassword gives a never-used account its first password. // // ── Why this endpoint exists ──────────────────────────────────────────────── // // Because the flow was impossible without it. A branch login created by // `createtenantlocation` arrives with an empty password; the console signs in, // is told to set one, and does so — through `PUT /users/update`, which sits // behind the session guard. So the call answered "a session token is required; // sign in again" to a person who could not sign in, because they had no // password yet. Every such account was unusable. // // `publicWebPaths` has named `/users/setpassword` since the guard was written. // The path was reserved and the handler never built, so it answered 404 and the // console went on using the guarded one. // // ── Why not simply open up `/users/update` ────────────────────────────────── // // It writes whatever struct it is handed. Unauthenticated, it would let anybody // change any field of any user — their email, their role, their tenant. This // takes two fields and can only act on an account with no password, which is // what makes it safe to leave open. See the repository for the rest. func (ctl *UserController) SetPassword(c *fiber.Ctx) error { var req struct { // The invitation, exactly as it arrived in the emailed link. The userid // is read out of the signature and never out of the request — see below. Token string `json:"token"` Password string `json:"password"` } if err := c.BodyParser(&req); err != nil { return c.Status(http.StatusBadRequest).JSON(fiber.Map{ "status": false, "code": http.StatusBadRequest, "message": "Invalid request body", }) } // ── Why this takes a token and no longer takes a userid ───────────────── // // It used to accept `{userid, password}`, and that was an account takeover // waiting to be noticed. `applogin` answers a POST carrying an email and NO // password with 409 and the userid, for any account that has not set one — // which is how the console's own setup step learned it. So the whole recipe // was: know a merchant's primary email, which is usually printed on their // shopfront, POST it here, receive their userid, then set their password // and own the business's admin account. No guessing at any step. // // The invitation closes it. It is signed with the deployment's key, names // the account in a payload the server produced, and expires. Knowing an // email is no longer enough, and neither is knowing a userid. claims, err := utils.ParseInviteToken(req.Token, time.Now()) if err != nil { return c.Status(http.StatusConflict).JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": err.Error(), }) } if err := ctl.userService.SetInitialPassword(claims.Userid, req.Password); err != nil { // 409, not 401. Nothing about this is an authentication failure — the // caller is not supposed to have a session — and answering 401 would // send the console into its sign-out-and-reload path on the one screen // where there is nothing to sign out of. return c.Status(http.StatusConflict).JSON(fiber.Map{ "status": false, "code": http.StatusConflict, "message": err.Error(), }) } return c.JSON(fiber.Map{ "status": true, "code": http.StatusOK, "message": "Password set. Sign in with it.", }) }