feat: account connect

This commit is contained in:
Krispire Admin
2026-06-02 22:54:41 +05:30
parent cf3a49458a
commit 3f6b7b6636
8 changed files with 429 additions and 22 deletions
+61 -1
View File
@@ -53,6 +53,12 @@ type verifyReq struct {
Token string `json:"token"`
}
type verifyLoginOTPReq struct {
Email string `json:"email"`
Password string `json:"password"`
OTP string `json:"otp"`
}
type setEmailReq struct {
Email string `json:"email"`
}
@@ -92,7 +98,14 @@ func (h *AuthHandler) SignUp(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusCreated, res.User)
}
// LogIn authenticates an existing user.
// LogIn authenticates an existing user. The response is one of:
//
// - 200 OK with the User JSON — credentials verified, session cookie set,
// frontend treats this as fully signed in.
// - 202 Accepted with `{ "otp_required": true }` — credentials verified
// but the account has been unverified for over 30 days. An OTP has been
// emailed; frontend must collect it and call VerifyLoginOTP next. No
// session cookie is set on this branch.
func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
var req logInReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
@@ -112,6 +125,40 @@ func (h *AuthHandler) LogIn(w http.ResponseWriter, r *http.Request) {
h.renderAuthErr(w, err)
return
}
if res.OTPRequired {
response.JSON(w, http.StatusAccepted, map[string]any{
"otp_required": true,
})
return
}
auth.Set(w, res.Auth.Token, res.Auth.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.Auth.User)
}
// VerifyLoginOTP completes a login that was gated by the stale-unverified
// OTP path. Re-checks the password (the OTP alone isn't enough — an
// attacker who steals the OTP email still needs the password), validates
// the OTP, flips email_verified_at to now(), and sets the session cookie.
func (h *AuthHandler) VerifyLoginOTP(w http.ResponseWriter, r *http.Request) {
var req verifyLoginOTPReq
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)
validator.Required(v, "otp", req.OTP)
if !v.Valid() {
response.Error(w, http.StatusBadRequest, v.Error())
return
}
res, err := h.svc.VerifyLoginOTP(r.Context(), req.Email, req.Password, req.OTP)
if err != nil {
h.renderAuthErr(w, err)
return
}
auth.Set(w, res.Token, res.Expires, h.cookies)
response.JSON(w, http.StatusOK, res.User)
}
@@ -260,6 +307,19 @@ func (h *AuthHandler) SetEmail(w http.ResponseWriter, r *http.Request) {
response.JSON(w, http.StatusOK, u)
}
// LinkedProviders — protected — returns the OAuth provider names already
// linked to the caller's account. Settings UI uses this to flip each
// social-connect row between "Connect" and "Connected".
func (h *AuthHandler) LinkedProviders(w http.ResponseWriter, r *http.Request) {
uid := middleware.UserID(r.Context())
providers, err := h.svc.LinkedProviders(r.Context(), uid)
if err != nil {
response.Error(w, http.StatusInternalServerError, "could not load linked providers")
return
}
response.JSON(w, http.StatusOK, map[string]any{"providers": providers})
}
// ResendVerification — protected — refires the verification email for the
// currently-logged-in user.
func (h *AuthHandler) ResendVerification(w http.ResponseWriter, r *http.Request) {