feat: Portal, Email Inbound, Discuss + module improvements
- 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>
This commit is contained in:
313
pkg/server/portal_signup.go
Normal file
313
pkg/server/portal_signup.go
Normal file
@@ -0,0 +1,313 @@
|
||||
// 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",
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user