Fix navbar: SVG logos, Settings menu, Logout handler
- Image handler now returns SVG placeholders for company logo and user avatars instead of broken 1x1 PNG (fixes yellow border in navbar) - Supports both query-param (?model=&field=) and path-style URLs (/web/image/res.partner/2/avatar_128) - Added Settings app menu with Users & Technical sub-menus - Added actions for Settings (company form), Users list, Sequences - Added /web/session/logout handler that clears session + cookie - Added logout to middleware whitelist Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
BIN
odoo-server
BIN
odoo-server
Binary file not shown.
@@ -241,6 +241,52 @@ func (s *Server) handleActionLoad(w http.ResponseWriter, r *http.Request) {
|
||||
"help": "",
|
||||
"xml_id": "fleet.action_fleet_vehicle",
|
||||
},
|
||||
100: {
|
||||
"id": 100,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Settings",
|
||||
"res_model": "res.company",
|
||||
"res_id": 1,
|
||||
"view_mode": "form",
|
||||
"views": [][]interface{}{{nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "base.action_res_company_form",
|
||||
},
|
||||
101: {
|
||||
"id": 101,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Users",
|
||||
"res_model": "res.users",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "base.action_res_users",
|
||||
},
|
||||
102: {
|
||||
"id": 102,
|
||||
"type": "ir.actions.act_window",
|
||||
"name": "Sequences",
|
||||
"res_model": "ir.sequence",
|
||||
"view_mode": "list,form",
|
||||
"views": [][]interface{}{{nil, "list"}, {nil, "form"}},
|
||||
"search_view_id": false,
|
||||
"domain": "[]",
|
||||
"context": "{}",
|
||||
"target": "current",
|
||||
"limit": 80,
|
||||
"help": "",
|
||||
"xml_id": "base.ir_sequence_form",
|
||||
},
|
||||
}
|
||||
|
||||
action, ok := actions[actionID]
|
||||
|
||||
@@ -2,16 +2,54 @@ package server
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// handleImage serves placeholder images for model records.
|
||||
// The real Odoo serves actual uploaded images from ir.attachment.
|
||||
// For now, return a 1x1 transparent PNG placeholder.
|
||||
// handleImage serves images for model records.
|
||||
// Mirrors: odoo/addons/web/controllers/binary.py content_image()
|
||||
// For now, return SVG placeholders that look like real Odoo avatars.
|
||||
func (s *Server) handleImage(w http.ResponseWriter, r *http.Request) {
|
||||
// 1x1 transparent PNG
|
||||
field := r.URL.Query().Get("field")
|
||||
model := r.URL.Query().Get("model")
|
||||
|
||||
// Also parse path-style URLs: /web/image/res.partner/2/avatar_128
|
||||
path := strings.TrimPrefix(r.URL.Path, "/web/image/")
|
||||
if model == "" && path != "" && path != r.URL.Path {
|
||||
pathParts := strings.Split(path, "/")
|
||||
if len(pathParts) >= 3 {
|
||||
model = pathParts[0]
|
||||
field = pathParts[2]
|
||||
} else if len(pathParts) >= 1 {
|
||||
model = pathParts[0]
|
||||
}
|
||||
}
|
||||
|
||||
// Company logo: return a styled SVG with company initial
|
||||
if model == "res.company" && (field == "logo" || field == "logo_web") {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="100" height="30" viewBox="0 0 100 30">
|
||||
<rect width="100" height="30" rx="4" fill="#71639e"/>
|
||||
<text x="50" y="20" font-family="sans-serif" font-size="14" fill="white" text-anchor="middle">Odoo</text>
|
||||
</svg>`))
|
||||
return
|
||||
}
|
||||
|
||||
// User avatar: return a colored circle with initial
|
||||
if (model == "res.partner" || model == "res.users") &&
|
||||
(field == "avatar_128" || field == "avatar_256" || field == "image_128" || field == "image_256" || strings.HasPrefix(field, "avatar") || strings.HasPrefix(field, "image")) {
|
||||
w.Header().Set("Content-Type", "image/svg+xml")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="64" height="64" viewBox="0 0 64 64">
|
||||
<rect width="64" height="64" rx="32" fill="#71639e"/>
|
||||
<text x="32" y="40" font-family="sans-serif" font-size="28" fill="white" text-anchor="middle">U</text>
|
||||
</svg>`))
|
||||
return
|
||||
}
|
||||
|
||||
// Default: 1x1 transparent PNG
|
||||
w.Header().Set("Content-Type", "image/png")
|
||||
w.Header().Set("Cache-Control", "public, max-age=3600")
|
||||
// Minimal valid 1x1 transparent PNG (67 bytes)
|
||||
png := []byte{
|
||||
0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d,
|
||||
0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01,
|
||||
|
||||
@@ -16,7 +16,7 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
|
||||
"root": map[string]interface{}{
|
||||
"id": "root",
|
||||
"name": "root",
|
||||
"children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9},
|
||||
"children": []int{1, 2, 3, 4, 5, 6, 7, 8, 9, 100},
|
||||
"appID": false,
|
||||
"xmlid": "",
|
||||
"actionID": false,
|
||||
@@ -330,6 +330,49 @@ func (s *Server) handleLoadMenus(w http.ResponseWriter, r *http.Request) {
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
// Settings
|
||||
"100": map[string]interface{}{
|
||||
"id": 100,
|
||||
"name": "Settings",
|
||||
"children": []int{101, 102},
|
||||
"appID": 100,
|
||||
"xmlid": "base.menu_administration",
|
||||
"actionID": 100,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": "fa-cog,#71639e,#FFFFFF",
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"101": map[string]interface{}{
|
||||
"id": 101,
|
||||
"name": "Users & Companies",
|
||||
"children": []int{},
|
||||
"appID": 100,
|
||||
"xmlid": "base.menu_users",
|
||||
"actionID": 101,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
"102": map[string]interface{}{
|
||||
"id": 102,
|
||||
"name": "Technical",
|
||||
"children": []int{},
|
||||
"appID": 100,
|
||||
"xmlid": "base.menu_custom",
|
||||
"actionID": 102,
|
||||
"actionModel": "ir.actions.act_window",
|
||||
"actionPath": false,
|
||||
"webIcon": nil,
|
||||
"webIconData": nil,
|
||||
"webIconDataMimetype": nil,
|
||||
"backgroundImage": nil,
|
||||
},
|
||||
}
|
||||
|
||||
json.NewEncoder(w).Encode(menus)
|
||||
|
||||
@@ -47,6 +47,7 @@ func AuthMiddleware(store *SessionStore, next http.Handler) http.Handler {
|
||||
if path == "/health" ||
|
||||
path == "/web/login" ||
|
||||
path == "/web/session/authenticate" ||
|
||||
path == "/web/session/logout" ||
|
||||
strings.HasPrefix(path, "/web/database/") ||
|
||||
path == "/web/webclient/version_info" ||
|
||||
strings.Contains(path, "/static/") {
|
||||
|
||||
@@ -119,6 +119,9 @@ func (s *Server) registerRoutes() {
|
||||
// File upload
|
||||
s.mux.HandleFunc("/web/binary/upload_attachment", s.handleUpload)
|
||||
|
||||
// Logout
|
||||
s.mux.HandleFunc("/web/session/logout", s.handleLogout)
|
||||
|
||||
// Health check
|
||||
s.mux.HandleFunc("/health", s.handleHealth)
|
||||
|
||||
@@ -796,6 +799,22 @@ func (s *Server) handleVersionInfo(w http.ResponseWriter, r *http.Request) {
|
||||
}, nil)
|
||||
}
|
||||
|
||||
// handleLogout destroys the session and redirects to login.
|
||||
// Mirrors: odoo/addons/web/controllers/home.py Home.web_logout()
|
||||
func (s *Server) handleLogout(w http.ResponseWriter, r *http.Request) {
|
||||
if cookie, err := r.Cookie("session_id"); err == nil && cookie.Value != "" {
|
||||
s.sessions.Delete(cookie.Value)
|
||||
}
|
||||
http.SetCookie(w, &http.Cookie{
|
||||
Name: "session_id",
|
||||
Value: "",
|
||||
Path: "/",
|
||||
MaxAge: -1,
|
||||
HttpOnly: true,
|
||||
})
|
||||
http.Redirect(w, r, "/web/login", http.StatusFound)
|
||||
}
|
||||
|
||||
func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
|
||||
err := s.pool.Ping(context.Background())
|
||||
if err != nil {
|
||||
|
||||
Reference in New Issue
Block a user