Files
backend_fiesta/controllers/userController.go
2026-09-29 16:53:21 +05:30

402 lines
12 KiB
Go

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.",
})
}