- Portal: /my/* routes, signup, password reset, portal user support - Email Inbound: IMAP polling (go-imap/v2), thread matching - Discuss: mail.channel, long-polling bus, DM, unread count - Cron: ir.cron runner (goroutine scheduler) - Bank Import, CSV/Excel Import - Automation (ir.actions.server) - Fetchmail service - HR Payroll model - Various fixes across account, sale, stock, purchase, crm, hr, project Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
314 lines
10 KiB
Go
314 lines
10 KiB
Go
// 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(`<html><body>
|
|
<p>A password reset was requested for your account.</p>
|
|
<p>Click the link below to set a new password:</p>
|
|
<p><a href="%s">Reset Password</a></p>
|
|
<p>This link expires in 24 hours.</p>
|
|
<p>If you did not request this, you can ignore this email.</p>
|
|
</body></html>`, 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",
|
|
})
|
|
}
|