Files
goodie/pkg/server/setup.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

531 lines
20 KiB
Go

package server
import (
"context"
"encoding/json"
"fmt"
"log"
"net/http"
"os"
"regexp"
"strings"
"sync/atomic"
"time"
"odoo-go/pkg/service"
"odoo-go/pkg/tools"
)
var dbnamePattern = regexp.MustCompile(`^[a-zA-Z0-9][a-zA-Z0-9_.-]+$`)
// isSetupNeeded checks if the current database has been initialized.
func (s *Server) isSetupNeeded() bool {
var count int
err := s.pool.QueryRow(context.Background(),
`SELECT COUNT(*) FROM res_company`).Scan(&count)
return err != nil || count == 0
}
// handleDatabaseManager serves the database manager page.
// Mirrors: odoo/addons/web/controllers/database.py Database.manager()
func (s *Server) handleDatabaseManager(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Write([]byte(databaseManagerHTML))
}
// handleDatabaseCreate processes the database creation form.
// Mirrors: odoo/addons/web/controllers/database.py Database.create()
// Fields match Python Odoo: name, login, password, phone, lang, country_code, demo
func (s *Server) handleDatabaseCreate(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "Method not allowed", http.StatusMethodNotAllowed)
return
}
var params struct {
MasterPwd string `json:"master_pwd"`
Name string `json:"name"`
Login string `json:"login"`
Password string `json:"password"`
Phone string `json:"phone"`
Lang string `json:"lang"`
CountryCode string `json:"country_code"`
Demo bool `json:"demo"`
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
writeJSON(w, map[string]string{"error": "Invalid request"})
return
}
// Validate master password (default: "admin", configurable via ODOO_MASTER_PASSWORD env)
masterPw := os.Getenv("ODOO_MASTER_PASSWORD")
if masterPw == "" {
masterPw = "admin"
}
if params.MasterPwd != masterPw {
writeJSON(w, map[string]string{"error": "Invalid master password"})
return
}
// Validate
if params.Login == "" || params.Password == "" {
writeJSON(w, map[string]string{"error": "Email and password are required"})
return
}
if len(params.Password) < 4 {
writeJSON(w, map[string]string{"error": "Password must be at least 4 characters"})
return
}
// Default values
if params.Lang == "" {
params.Lang = "en_US"
}
if params.CountryCode == "" {
params.CountryCode = "DE"
}
// Map country code
countryName := "Germany"
phoneCode := "49"
switch strings.ToUpper(params.CountryCode) {
case "AT":
countryName = "Austria"
phoneCode = "43"
case "CH":
countryName = "Switzerland"
phoneCode = "41"
case "US":
countryName = "United States"
phoneCode = "1"
case "GB":
countryName = "United Kingdom"
phoneCode = "44"
case "FR":
countryName = "France"
phoneCode = "33"
}
// Determine chart of accounts from country
chart := "skr03"
switch strings.ToUpper(params.CountryCode) {
case "AT", "CH":
chart = "skr03" // Use SKR03 for DACH region
default:
chart = "skr03"
}
// Extract company name from email domain, or use default
companyName := "My Company"
if strings.Contains(params.Login, "@") {
parts := strings.Split(params.Login, "@")
if len(parts) == 2 {
domain := parts[1]
domainParts := strings.Split(domain, ".")
if len(domainParts) > 0 {
name := domainParts[0]
if len(name) > 0 {
companyName = strings.ToUpper(name[:1]) + name[1:]
}
}
}
}
log.Printf("setup: creating database for %q (login: %s, country: %s)", companyName, params.Login, params.CountryCode)
// Hash password
hashedPw, err := tools.HashPassword(params.Password)
if err != nil {
writeJSON(w, map[string]string{"error": fmt.Sprintf("Password error: %v", err)})
return
}
// Seed the database
setupCfg := service.SetupConfig{
CompanyName: companyName,
CountryCode: strings.ToUpper(params.CountryCode),
CountryName: countryName,
PhoneCode: phoneCode,
Phone: params.Phone,
Email: params.Login,
Chart: chart,
AdminLogin: params.Login,
AdminPassword: hashedPw,
DemoData: params.Demo,
}
if err := service.SeedWithSetup(context.Background(), s.pool, setupCfg); err != nil {
log.Printf("setup: error: %v", err)
writeJSON(w, map[string]string{"error": fmt.Sprintf("Database error: %v", err)})
return
}
// Auto-login: create session and return session cookie
// Mirrors: odoo/addons/web/controllers/database.py line 82-88
sess := s.sessions.New(1, 1, params.Login)
http.SetCookie(w, &http.Cookie{
Name: "session_id",
Value: sess.ID,
Path: "/",
HttpOnly: true,
SameSite: http.SameSiteLaxMode,
})
log.Printf("setup: database initialized, auto-login as %q", params.Login)
writeJSON(w, map[string]interface{}{
"status": "ok",
"session_id": sess.ID,
"redirect": "/odoo",
})
}
// handleDatabaseList returns the list of databases.
// Mirrors: odoo/addons/web/controllers/database.py Database.list()
func (s *Server) handleDatabaseListJSON(w http.ResponseWriter, r *http.Request) {
writeJSON(w, []string{s.config.DBName})
}
func writeJSON(w http.ResponseWriter, v interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(v)
}
// postSetupDone caches the result of isPostSetupNeeded to avoid a DB query on every request.
var postSetupDone atomic.Bool
// isPostSetupNeeded checks if the company still has default values (needs configuration).
func (s *Server) isPostSetupNeeded() bool {
if postSetupDone.Load() {
return false
}
var name string
err := s.pool.QueryRow(context.Background(),
`SELECT COALESCE(name, '') FROM res_company WHERE id = 1`).Scan(&name)
if err != nil {
return false
}
needed := name == "" || name == "My Company" || strings.HasPrefix(name, "My ")
if !needed {
postSetupDone.Store(true)
}
return needed
}
// handleSetupWizard serves the post-setup configuration wizard.
// Shown after first login when the company has not been configured yet.
// Mirrors: odoo/addons/base_setup/views/res_config_settings_views.xml
func (s *Server) handleSetupWizard(w http.ResponseWriter, r *http.Request) {
sess := GetSession(r)
if sess == nil {
http.Redirect(w, r, "/web/login", http.StatusFound)
return
}
// Load current company data
var companyName, street, city, zip, phone, email, website, vat string
var countryID int64
s.pool.QueryRow(context.Background(),
`SELECT COALESCE(name,''), COALESCE(street,''), COALESCE(city,''), COALESCE(zip,''),
COALESCE(phone,''), COALESCE(email,''), COALESCE(website,''), COALESCE(vat,''),
COALESCE(country_id, 0)
FROM res_company WHERE id = $1`, sess.CompanyID,
).Scan(&companyName, &street, &city, &zip, &phone, &email, &website, &vat, &countryID)
w.Header().Set("Content-Type", "text/html; charset=utf-8")
esc := htmlEscape
fmt.Fprintf(w, setupWizardHTML,
esc(companyName), esc(street), esc(city), esc(zip), esc(phone), esc(email), esc(website), esc(vat))
}
// handleSetupWizardSave saves the post-setup wizard data.
func (s *Server) handleSetupWizardSave(w http.ResponseWriter, r *http.Request) {
sess := GetSession(r)
if sess == nil {
writeJSON(w, map[string]string{"error": "Not authenticated"})
return
}
var params struct {
CompanyName string `json:"company_name"`
Street string `json:"street"`
City string `json:"city"`
Zip string `json:"zip"`
Phone string `json:"phone"`
Email string `json:"email"`
Website string `json:"website"`
Vat string `json:"vat"`
}
if err := json.NewDecoder(r.Body).Decode(&params); err != nil {
writeJSON(w, map[string]string{"error": "Invalid request"})
return
}
if params.CompanyName == "" {
writeJSON(w, map[string]string{"error": "Company name is required"})
return
}
_, err := s.pool.Exec(context.Background(),
`UPDATE res_company SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
WHERE id = $9`,
params.CompanyName, params.Street, params.City, params.Zip,
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
if err != nil {
writeJSON(w, map[string]string{"error": fmt.Sprintf("Save error: %v", err)})
return
}
// Also update the partner linked to the company
s.pool.Exec(context.Background(),
`UPDATE res_partner SET name=$1, street=$2, city=$3, zip=$4, phone=$5, email=$6, website=$7, vat=$8
WHERE id = (SELECT partner_id FROM res_company WHERE id = $9)`,
params.CompanyName, params.Street, params.City, params.Zip,
params.Phone, params.Email, params.Website, params.Vat, sess.CompanyID)
postSetupDone.Store(true) // Mark setup as done so we don't redirect again
writeJSON(w, map[string]interface{}{"status": "ok", "redirect": "/odoo"})
}
var setupWizardHTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Setup — Configure Your Company</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.wizard { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 100%%; max-width: 560px; }
.wizard h1 { color: #71639e; margin-bottom: 6px; font-size: 24px; }
.wizard .subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
.wizard label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
.wizard input { width: 100%%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
font-size: 14px; margin-bottom: 14px; }
.wizard input:focus { outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
.wizard button { width: 100%%; padding: 14px; background: #71639e; color: white; border: none;
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 10px; }
.wizard button:hover { background: #5f5387; }
.wizard .skip { text-align: center; margin-top: 12px; }
.wizard .skip a { color: #999; text-decoration: none; font-size: 13px; }
.wizard .skip a:hover { color: #666; }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; font-size: 14px; }
</style>
</head>
<body>
<div class="wizard">
<h1>Configure Your Company</h1>
<p class="subtitle">Set up your company information</p>
<div id="error" class="error"></div>
<form id="wizardForm">
<label>Company Name *</label>
<input type="text" id="company_name" value="%s" required/>
<label>Street</label>
<input type="text" id="street" value="%s"/>
<div class="row">
<div><label>City</label><input type="text" id="city" value="%s"/></div>
<div><label>ZIP</label><input type="text" id="zip" value="%s"/></div>
</div>
<div class="row">
<div><label>Phone</label><input type="tel" id="phone" value="%s"/></div>
<div><label>Email</label><input type="email" id="email" value="%s"/></div>
</div>
<label>Website</label>
<input type="url" id="website" value="%s" placeholder="https://"/>
<label>Tax ID / VAT</label>
<input type="text" id="vat" value="%s"/>
<button type="submit">Save & Continue</button>
</form>
<div class="skip"><a href="/odoo">Skip for now</a></div>
</div>
<script>
document.getElementById('wizardForm').addEventListener('submit', function(e) {
e.preventDefault();
fetch('/web/setup/wizard/save', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
company_name: document.getElementById('company_name').value,
street: document.getElementById('street').value,
city: document.getElementById('city').value,
zip: document.getElementById('zip').value,
phone: document.getElementById('phone').value,
email: document.getElementById('email').value,
website: document.getElementById('website').value,
vat: document.getElementById('vat').value
})
})
.then(function(r) { return r.json(); })
.then(function(result) {
if (result.error) {
var el = document.getElementById('error');
el.textContent = result.error;
el.style.display = 'block';
} else {
window.location.href = result.redirect || '/odoo';
}
});
});
</script>
</body>
</html>`
// --- Database Manager HTML ---
// Mirrors: odoo/addons/web/static/src/public/database_manager.create_form.qweb.html
var databaseManagerHTML = `<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Odoo — Database Manager</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
background: #f0eeee; display: flex; align-items: center; justify-content: center; min-height: 100vh; }
.db-manager { background: white; padding: 40px; border-radius: 8px; box-shadow: 0 2px 10px rgba(0,0,0,0.1);
width: 100%; max-width: 480px; }
.db-manager h1 { color: #71639e; margin-bottom: 6px; font-size: 24px; }
.db-manager .subtitle { color: #666; margin-bottom: 24px; font-size: 14px; }
.db-manager label { display: block; margin-bottom: 4px; font-weight: 500; color: #555; font-size: 13px; }
.db-manager input, .db-manager select {
width: 100%; padding: 9px 12px; border: 1px solid #ddd; border-radius: 4px;
font-size: 14px; margin-bottom: 14px; }
.db-manager input:focus, .db-manager select:focus {
outline: none; border-color: #71639e; box-shadow: 0 0 0 2px rgba(113,99,158,0.2); }
.db-manager button { width: 100%; padding: 14px; background: #71639e; color: white; border: none;
border-radius: 4px; font-size: 16px; cursor: pointer; margin-top: 16px; }
.db-manager button:hover { background: #5f5387; }
.db-manager button:disabled { background: #aaa; cursor: not-allowed; }
.error { color: #dc3545; margin-bottom: 12px; display: none; text-align: center; font-size: 14px; }
.check { display: flex; align-items: center; gap: 8px; margin: 8px 0 4px; }
.check input { width: auto; margin: 0; }
.check label { margin: 0; }
.row { display: flex; gap: 12px; }
.row > div { flex: 1; }
.progress { display: none; text-align: center; padding: 30px; }
.progress .spinner { font-size: 36px; animation: spin 1s linear infinite; display: inline-block; }
@keyframes spin { to { transform: rotate(360deg); } }
.hint { color: #999; font-size: 12px; margin-top: -10px; margin-bottom: 12px; }
</style>
</head>
<body>
<div class="db-manager">
<h1>Create Database</h1>
<p class="subtitle">Set up your Odoo database</p>
<div id="error" class="error"></div>
<form id="createForm">
<label for="master_pwd">Master Password</label>
<input type="password" id="master_pwd" name="master_pwd" value="admin" placeholder="Master password"/>
<p class="hint">Default: admin</p>
<label for="login">Email *</label>
<input type="email" id="login" name="login" required placeholder="admin@example.com"/>
<label for="password">Password *</label>
<input type="password" id="password" name="password" required minlength="4" placeholder="Min. 4 characters"/>
<label for="phone">Phone</label>
<input type="tel" id="phone" name="phone" placeholder="+49 30 12345678"/>
<div class="row">
<div>
<label for="lang">Language</label>
<select id="lang" name="lang">
<option value="en_US">English (US)</option>
<option value="de_DE" selected>German / Deutsch</option>
<option value="fr_FR">French / Fran&ccedil;ais</option>
</select>
</div>
<div>
<label for="country_code">Country</label>
<select id="country_code" name="country_code">
<option value="DE" selected>Germany</option>
<option value="AT">Austria</option>
<option value="CH">Switzerland</option>
<option value="US">United States</option>
<option value="GB">United Kingdom</option>
<option value="FR">France</option>
</select>
</div>
</div>
<div class="check">
<input type="checkbox" id="demo" name="demo" checked/>
<label for="demo">Load demonstration data</label>
</div>
<button type="submit" id="submitBtn">Create Database</button>
</form>
<div id="progress" class="progress">
<div class="spinner">&#10227;</div>
<p style="margin-top:12px;color:#666;">Creating database...</p>
</div>
</div>
<script>
document.getElementById('createForm').addEventListener('submit', function(e) {
e.preventDefault();
var btn = document.getElementById('submitBtn');
var form = document.getElementById('createForm');
var progress = document.getElementById('progress');
var errorEl = document.getElementById('error');
btn.disabled = true;
errorEl.style.display = 'none';
var data = {
master_pwd: document.getElementById('master_pwd').value,
login: document.getElementById('login').value,
password: document.getElementById('password').value,
phone: document.getElementById('phone').value,
lang: document.getElementById('lang').value,
country_code: document.getElementById('country_code').value,
demo: document.getElementById('demo').checked
};
form.style.display = 'none';
progress.style.display = 'block';
fetch('/web/database/create', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
})
.then(function(r) { return r.json(); })
.then(function(result) {
if (result.error) {
form.style.display = 'block';
progress.style.display = 'none';
errorEl.textContent = result.error;
errorEl.style.display = 'block';
btn.disabled = false;
} else {
// Auto-login succeeded, redirect to webclient
window.location.href = result.redirect || '/odoo';
}
})
.catch(function(err) {
form.style.display = 'block';
progress.style.display = 'none';
errorEl.textContent = 'Connection error: ' + err.message;
errorEl.style.display = 'block';
btn.disabled = false;
});
});
</script>
</body>
</html>` + fmt.Sprintf("<!-- generated at %s -->", time.Now().Format(time.RFC3339))