Files
goodie/pkg/server/portal_signup.go
Marc 66383adf06 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>
2026-04-12 18:41:57 +02:00

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",
})
}