// Package server — Portal signup and password reset. // Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome package server import ( "context" "crypto/rand" "encoding/hex" "encoding/json" "fmt" "log" "net/http" "strings" "time" "odoo-go/pkg/tools" ) // registerPortalSignupRoutes registers /web/portal/* public endpoints. func (s *Server) registerPortalSignupRoutes() { s.mux.HandleFunc("/web/portal/signup", s.handlePortalSignup) s.mux.HandleFunc("/web/portal/reset_password", s.handlePortalResetPassword) } // handlePortalSignup creates a new portal user with share=true and a matching res.partner. // Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_signup() func (s *Server) handlePortalSignup(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writePortalError(w, http.StatusMethodNotAllowed, "POST required") return } var body struct { Name string `json:"name"` Email string `json:"email"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writePortalError(w, http.StatusBadRequest, "Invalid JSON") return } // Validate required fields body.Name = strings.TrimSpace(body.Name) body.Email = strings.TrimSpace(body.Email) if body.Name == "" || body.Email == "" || body.Password == "" { writePortalError(w, http.StatusBadRequest, "Name, email, and password are required") return } if len(body.Password) < 8 { writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters") return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() // Check if login already exists var exists bool err := s.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM res_users WHERE login = $1)`, body.Email).Scan(&exists) if err != nil { log.Printf("portal signup: check existing user error: %v", err) writePortalError(w, http.StatusInternalServerError, "Internal error") return } if exists { writePortalError(w, http.StatusConflict, "An account with this email already exists") return } // Hash password hashedPw, err := tools.HashPassword(body.Password) if err != nil { log.Printf("portal signup: hash password error: %v", err) writePortalError(w, http.StatusInternalServerError, "Internal error") return } // Get default company var companyID int64 err = s.pool.QueryRow(ctx, `SELECT id FROM res_company WHERE active = true ORDER BY id LIMIT 1`).Scan(&companyID) if err != nil { log.Printf("portal signup: get company error: %v", err) writePortalError(w, http.StatusInternalServerError, "Internal error") return } // Begin transaction — create partner + user atomically tx, err := s.pool.Begin(ctx) if err != nil { log.Printf("portal signup: begin tx error: %v", err) writePortalError(w, http.StatusInternalServerError, "Internal error") return } defer tx.Rollback(ctx) // Create res.partner var partnerID int64 err = tx.QueryRow(ctx, `INSERT INTO res_partner (name, email, active, company_id, customer_rank) VALUES ($1, $2, true, $3, 1) RETURNING id`, body.Name, body.Email, companyID).Scan(&partnerID) if err != nil { log.Printf("portal signup: create partner error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to create account") return } // Create res.users with share=true var userID int64 err = tx.QueryRow(ctx, `INSERT INTO res_users (login, password, active, partner_id, company_id, share) VALUES ($1, $2, true, $3, $4, true) RETURNING id`, body.Email, hashedPw, partnerID, companyID).Scan(&userID) if err != nil { log.Printf("portal signup: create user error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to create account") return } // Add user to group_portal (not group_user) var groupPortalID int64 err = tx.QueryRow(ctx, `SELECT g.id FROM res_groups g JOIN ir_model_data imd ON imd.res_id = g.id AND imd.model = 'res.groups' WHERE imd.module = 'base' AND imd.name = 'group_portal'`).Scan(&groupPortalID) if err != nil { // group_portal might not exist yet — create it err = tx.QueryRow(ctx, `INSERT INTO res_groups (name) VALUES ('Portal') RETURNING id`).Scan(&groupPortalID) if err != nil { log.Printf("portal signup: create group_portal error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to create account") return } _, err = tx.Exec(ctx, `INSERT INTO ir_model_data (module, name, model, res_id) VALUES ('base', 'group_portal', 'res.groups', $1) ON CONFLICT DO NOTHING`, groupPortalID) if err != nil { log.Printf("portal signup: create group_portal xmlid error: %v", err) } } _, err = tx.Exec(ctx, `INSERT INTO res_groups_res_users_rel (res_groups_id, res_users_id) VALUES ($1, $2) ON CONFLICT DO NOTHING`, groupPortalID, userID) if err != nil { log.Printf("portal signup: add user to group_portal error: %v", err) } if err := tx.Commit(ctx); err != nil { log.Printf("portal signup: commit error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to create account") return } log.Printf("portal signup: created portal user id=%d login=%s partner_id=%d", userID, body.Email, partnerID) writePortalJSON(w, map[string]interface{}{ "success": true, "user_id": userID, "partner_id": partnerID, "message": "Account created successfully", }) } // handlePortalResetPassword handles password reset requests. // POST with {"email":"..."}: generates a reset token and sends an email. // POST with {"token":"...","password":"..."}: resets the password. // Mirrors: odoo/addons/auth_signup/controllers/main.py AuthSignupHome.web_auth_reset_password() func (s *Server) handlePortalResetPassword(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { writePortalError(w, http.StatusMethodNotAllowed, "POST required") return } var body struct { Email string `json:"email"` Token string `json:"token"` Password string `json:"password"` } if err := json.NewDecoder(r.Body).Decode(&body); err != nil { writePortalError(w, http.StatusBadRequest, "Invalid JSON") return } ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second) defer cancel() // Phase 2: Token + new password → reset if body.Token != "" && body.Password != "" { s.handleResetWithToken(w, ctx, body.Token, body.Password) return } // Phase 1: Email → generate token + send email if body.Email == "" { writePortalError(w, http.StatusBadRequest, "Email is required") return } s.handleResetRequest(w, ctx, strings.TrimSpace(body.Email)) } // handleResetRequest generates a reset token and sends it via email. func (s *Server) handleResetRequest(w http.ResponseWriter, ctx context.Context, email string) { // Look up user var uid int64 err := s.pool.QueryRow(ctx, `SELECT id FROM res_users WHERE login = $1 AND active = true`, email).Scan(&uid) if err != nil { // Don't reveal whether the email exists — always return success writePortalJSON(w, map[string]interface{}{ "success": true, "message": "If an account exists with this email, a reset link has been sent", }) return } // Generate token tokenBytes := make([]byte, 32) rand.Read(tokenBytes) token := hex.EncodeToString(tokenBytes) expiration := time.Now().Add(24 * time.Hour) // Store token _, err = s.pool.Exec(ctx, `UPDATE res_users SET signup_token = $1, signup_expiration = $2 WHERE id = $3`, token, expiration, uid) if err != nil { log.Printf("portal reset: store token error: %v", err) writePortalError(w, http.StatusInternalServerError, "Internal error") return } // Send email with reset link smtpCfg := tools.LoadSMTPConfig() resetURL := fmt.Sprintf("/web/portal/reset_password?token=%s", token) emailBody := fmt.Sprintf(`

A password reset was requested for your account.

Click the link below to set a new password:

Reset Password

This link expires in 24 hours.

If you did not request this, you can ignore this email.

`, resetURL) if err := tools.SendEmail(smtpCfg, email, "Password Reset", emailBody); err != nil { log.Printf("portal reset: send email error: %v", err) // Don't expose email sending errors to the user } writePortalJSON(w, map[string]interface{}{ "success": true, "message": "If an account exists with this email, a reset link has been sent", }) } // handleResetWithToken validates the token and sets the new password. func (s *Server) handleResetWithToken(w http.ResponseWriter, ctx context.Context, token, password string) { if len(password) < 8 { writePortalError(w, http.StatusBadRequest, "Password must be at least 8 characters") return } // Look up user by token var uid int64 var expiration time.Time err := s.pool.QueryRow(ctx, `SELECT id, signup_expiration FROM res_users WHERE signup_token = $1 AND active = true`, token).Scan(&uid, &expiration) if err != nil { writePortalError(w, http.StatusBadRequest, "Invalid or expired reset token") return } // Check expiration if time.Now().After(expiration) { // Clear expired token s.pool.Exec(ctx, `UPDATE res_users SET signup_token = NULL, signup_expiration = NULL WHERE id = $1`, uid) writePortalError(w, http.StatusBadRequest, "Reset token has expired") return } // Hash new password hashedPw, err := tools.HashPassword(password) if err != nil { log.Printf("portal reset: hash password error: %v", err) writePortalError(w, http.StatusInternalServerError, "Internal error") return } // Update password and clear token _, err = s.pool.Exec(ctx, `UPDATE res_users SET password = $1, signup_token = NULL, signup_expiration = NULL WHERE id = $2`, hashedPw, uid) if err != nil { log.Printf("portal reset: update password error: %v", err) writePortalError(w, http.StatusInternalServerError, "Failed to reset password") return } log.Printf("portal reset: password reset for uid=%d", uid) writePortalJSON(w, map[string]interface{}{ "success": true, "message": "Password has been reset successfully", }) }