package handler import ( "encoding/json" "errors" "net/http" "github.com/prabhupadasays/spsays-bff/internal/middleware" "github.com/prabhupadasays/spsays-bff/internal/service" "github.com/prabhupadasays/spsays-bff/pkg/auth" "github.com/prabhupadasays/spsays-bff/pkg/response" "github.com/prabhupadasays/spsays-bff/pkg/validator" ) // AuthHandler exposes the auth-related HTTP endpoints. // // Every handler is a 5-line shim: parse → validate → service → render. // All branching (already exists? wrong password? expired link?) lives in // AuthService and surfaces here only as sentinel errors mapped to status // codes via mapAuthErr. type AuthHandler struct { svc *service.AuthService cookies auth.CookieOptions } func NewAuthHandler(svc *service.AuthService, cookies auth.CookieOptions) *AuthHandler { return &AuthHandler{svc: svc, cookies: cookies} } // --- request shapes --- type signUpReq struct { Email string `json:"email"` Password string `json:"password"` Name string `json:"name"` } type logInReq struct { Email string `json:"email"` Password string `json:"password"` } type forgotReq struct { Email string `json:"email"` } type resetReq struct { Token string `json:"token"` NewPassword string `json:"new_password"` } type verifyReq struct { Token string `json:"token"` } // --- handlers --- // SignUp creates a user, plants the session cookie, returns the user. func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) { var req signUpReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Email(v, "email", req.Email) validator.Required(v, "password", req.Password) validator.MinLength(v, "password", req.Password, 8) validator.Required(v, "name", req.Name) validator.MaxLength(v, "name", req.Name, 120) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } res, err := h.svc.SignUp(r.Context(), req.Email, req.Password, req.Name) if err != nil { h.renderAuthErr(w, err) return } auth.Set(w, res.Token, res.Expires, h.cookies) response.JSON(w, http.StatusCreated, res.User) } // LogIn authenticates an existing user. func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) { var req logInReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Required(v, "password", req.Password) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } res, err := h.svc.LogIn(r.Context(), req.Email, req.Password) if err != nil { h.renderAuthErr(w, err) return } auth.Set(w, res.Token, res.Expires, h.cookies) response.JSON(w, http.StatusOK, res.User) } // LogOut just clears the cookie. JWTs remain technically valid until expiry, // but with the cookie gone the browser can't present them. func (h *AuthHandler) LogOut(w http.ResponseWriter, r *http.Request) { auth.Clear(w, h.cookies) response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // Me returns the authenticated user. Protected by middleware.RequireAuth. func (h *AuthHandler) Me(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) tv := middleware.TokenVersion(r.Context()) u, err := h.svc.Me(r.Context(), uid, tv) if err != nil { response.Error(w, http.StatusUnauthorized, "not authenticated") return } response.JSON(w, http.StatusOK, u) } // ForgotPassword emails a reset link if the address exists. We always return // 200 to avoid revealing whether the email is registered. func (h *AuthHandler) ForgotPassword(w http.ResponseWriter, r *http.Request) { var req forgotReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "email", req.Email) validator.Email(v, "email", req.Email) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } if err := h.svc.RequestPasswordReset(r.Context(), req.Email); err != nil { response.Error(w, http.StatusInternalServerError, "could not process request") return } response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // ResetPassword consumes a reset token and sets a new password. func (h *AuthHandler) ResetPassword(w http.ResponseWriter, r *http.Request) { var req resetReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } v := validator.New() validator.Required(v, "token", req.Token) validator.Required(v, "new_password", req.NewPassword) validator.MinLength(v, "new_password", req.NewPassword, 8) if !v.Valid() { response.Error(w, http.StatusBadRequest, v.Error()) return } if err := h.svc.ResetPassword(r.Context(), req.Token, req.NewPassword); err != nil { h.renderAuthErr(w, err) return } // Force the user to log in again after a reset (their token_version is now stale). auth.Clear(w, h.cookies) response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // VerifyEmail flips email_verified_at after the user clicks the link. func (h *AuthHandler) VerifyEmail(w http.ResponseWriter, r *http.Request) { var req verifyReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { response.Error(w, http.StatusBadRequest, "invalid JSON body") return } if req.Token == "" { response.Error(w, http.StatusBadRequest, "token is required") return } if err := h.svc.VerifyEmail(r.Context(), req.Token); err != nil { h.renderAuthErr(w, err) return } response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // ResendVerification — protected — refires the verification email for the // currently-logged-in user. func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) { uid := middleware.UserID(r.Context()) if err := h.svc.ResendVerification(r.Context(), uid); err != nil { h.renderAuthErr(w, err) return } response.JSON(w, http.StatusOK, map[string]bool{"ok": true}) } // --- error mapping --- func (h *AuthHandler) renderAuthErr(w http.ResponseWriter, err error) { switch { case errors.Is(err, service.ErrInvalidCredentials), errors.Is(err, service.ErrInvalidOrExpiredLink): response.Error(w, http.StatusUnauthorized, err.Error()) case errors.Is(err, service.ErrEmailAlreadyTaken): response.Error(w, http.StatusConflict, err.Error()) case errors.Is(err, service.ErrEmailAlreadyVerified), errors.Is(err, service.ErrWeakPassword): response.Error(w, http.StatusBadRequest, err.Error()) case errors.Is(err, service.ErrUserNotFound): response.Error(w, http.StatusNotFound, err.Error()) default: response.Error(w, http.StatusInternalServerError, "something went wrong") } }