diff --git a/addons/base/data/core_user_apikey_rule_data.xml b/addons/base/data/core_user_apikey_rule_data.xml new file mode 100644 index 00000000..ecc176af --- /dev/null +++ b/addons/base/data/core_user_apikey_rule_data.xml @@ -0,0 +1,16 @@ + + + + + Users: own API keys + core.user.apikey + [["user_id", "=", "$uid"]] + True + True + True + True + False + [(4, ref('base.group_user'))] + + + diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 14ed86f4..6630b355 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -7,6 +7,7 @@ "application": true, "data": [ "data/partner_property_definition_data.xml", + "data/core_user_apikey_rule_data.xml", "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", diff --git a/addons/base/views/core_user_apikey_form_views.xml b/addons/base/views/core_user_apikey_form_views.xml index 91a269dd..8b40e57e 100644 --- a/addons/base/views/core_user_apikey_form_views.xml +++ b/addons/base/views/core_user_apikey_form_views.xml @@ -12,10 +12,17 @@ + + + + + + diff --git a/addons/base/views/core_user_apikey_list_views.xml b/addons/base/views/core_user_apikey_list_views.xml index a22d82d3..78a8a4fa 100644 --- a/addons/base/views/core_user_apikey_list_views.xml +++ b/addons/base/views/core_user_apikey_list_views.xml @@ -5,6 +5,8 @@ + + diff --git a/core/engine/render/menu_entry.go b/core/engine/render/menu_entry.go new file mode 100644 index 00000000..0dbaeec7 --- /dev/null +++ b/core/engine/render/menu_entry.go @@ -0,0 +1,96 @@ +package render + +import ( + "context" + "fmt" + "strconv" + "strings" + + "sumeru/core/orm" +) + +// ModuleWorkspaceEntryURL is the user-facing link to open an application module (/web/{name}). +func ModuleWorkspaceEntryURL(ctx context.Context, moduleName string) string { + _ = ctx + if href := ModuleWebURL(moduleName); href != "" { + return href + } + return HomeWebURL() +} + +// MenuWorkspaceEntryURL builds the internal workspace URL for a menu row (action + menu_id). +func MenuWorkspaceEntryURL(ctx context.Context, menuID int) string { + if 0 >= menuID { + return HomeWebURL() + } + actionID, leafMenuID := orm.MenuEntryAction(ctx, menuID) + if 0 == actionID { + return HomeWebURL() + } + menuIDStr := strconv.Itoa(menuID) + if leafMenuID > 0 { + menuIDStr = strconv.Itoa(leafMenuID) + } + if href := urlActionHref(ctx, actionID); href != "" { + return href + } + return WorkspaceURL(WorkspaceQuery{ActionID: actionID, MenuID: menuIDStr}) +} + +// WorkspaceURLFromMenuAction builds the internal workspace URL from a resolved menu entry action. +func WorkspaceURLFromMenuAction(ctx context.Context, actionID, rootMenuID, leafMenuID int) string { + if actionID == 0 { + return HomeWebURL() + } + menuIDStr := strconv.Itoa(rootMenuID) + if leafMenuID > 0 { + menuIDStr = strconv.Itoa(leafMenuID) + } + if href := urlActionHref(ctx, actionID); href != "" { + return href + } + return WorkspaceURL(WorkspaceQuery{ActionID: actionID, MenuID: menuIDStr}) +} + +// WorkspaceURLWithDebug appends debug=true to a workspace URL when enabled. +func WorkspaceURLWithDebug(workspaceURL string, debugEnabled bool) string { + workspaceURL = strings.TrimSpace(workspaceURL) + if !debugEnabled || workspaceURL == "" { + return workspaceURL + } + if strings.Contains(workspaceURL, "debug=") { + return workspaceURL + } + sep := "?" + if strings.Contains(workspaceURL, "?") { + sep = "&" + } + return workspaceURL + sep + "debug=true" +} + +func urlActionHref(ctx context.Context, actionID int) string { + row, err := orm.SearchOne(ctx, "sys.action.url", map[string]interface{}{"id": actionID}) + if err != nil { + return "" + } + url := strings.TrimSpace(orm.AsString(row["url"])) + if url == "" || !strings.HasPrefix(url, "/") { + return "" + } + return url +} + +func menuItemWorkspaceHref(ctx context.Context, menuRowID int, directActionID int, urlHrefs map[int]string, moduleName string, isRoot bool) string { + if isRoot && strings.TrimSpace(moduleName) != "" { + if href := ModuleWebURL(moduleName); href != "" { + return href + } + } + if directActionID != 0 { + if href := urlHrefs[directActionID]; href != "" { + return href + } + return fmt.Sprintf("/web?action=%d&menu_id=%d", directActionID, menuRowID) + } + return MenuWorkspaceEntryURL(ctx, menuRowID) +} diff --git a/core/engine/render/menus.go b/core/engine/render/menus.go index 6ff207c5..e6ff6953 100644 --- a/core/engine/render/menus.go +++ b/core/engine/render/menus.go @@ -89,6 +89,11 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { continue } + isRoot := !parentID.Valid || parentID.Int64 == int64(id) + directAction := 0 + if actionID.Valid && actionID.Int64 != 0 { + directAction = int(actionID.Int64) + } m := parser.MenuItem{ ID: fmt.Sprintf("%d", id), Name: orm.Translate(ctx, lang, name), @@ -96,19 +101,14 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { Module: mod, WebIcon: sanitizeMenuIcon(webIcon), AccessGroups: strings.TrimSpace(accessGroups), - Action: fmt.Sprintf("/web?menu_id=%d", id), } if parentID.Valid && parentID.Int64 != int64(id) { m.ParentID = fmt.Sprintf("%d", parentID.Int64) } - if actionID.Valid && actionID.Int64 != 0 { - if href := urlActionHrefs[int(actionID.Int64)]; href != "" { - m.Action = href - } else { - m.Action = fmt.Sprintf("/web?action=%d&menu_id=%d", actionID.Int64, id) - } - } else if !parentID.Valid && strings.EqualFold(strings.TrimSpace(name), "Home") { - m.Action = "/web/home" + if isRoot && strings.EqualFold(strings.TrimSpace(name), "Home") { + m.Action = HomeWebURL() + } else { + m.Action = menuItemWorkspaceHref(ctx, id, directAction, urlActionHrefs, mod, isRoot) } allMenus = append(allMenus, m) } @@ -408,11 +408,8 @@ func BuildAppLauncherJSON(ctx context.Context) template.JS { if name == "" { continue } + href := ModuleWorkspaceEntryURL(ctx, name) menuID := RootMenuIDForModule(ctx, name) - href := "/web/home" - if menuID > 0 { - href = fmt.Sprintf("/web?menu_id=%d", menuID) - } items = append(items, appLauncherItem{ Kind: "app", Name: name, diff --git a/core/engine/render/shell_routes.go b/core/engine/render/shell_routes.go new file mode 100644 index 00000000..e19bf55d --- /dev/null +++ b/core/engine/render/shell_routes.go @@ -0,0 +1,86 @@ +package render + +import ( + "regexp" + "strings" +) + +const shellWebPrefix = "/web/" + +var moduleNameRE = regexp.MustCompile(`^[a-z][a-z0-9_]*$`) + +// ReservedWebSegments are /web/{segment} paths owned by the kernel, not application modules. +var ReservedWebSegments = map[string]struct{}{ + "login": {}, "logout": {}, "home": {}, "apps": {}, "settings": {}, + "import": {}, "export": {}, "bulk": {}, "swc": {}, "debug": {}, + "company": {}, "module": {}, "action": {}, "session": {}, "report": {}, + "kanban": {}, "setup": {}, "portal": {}, "api": {}, "user": {}, "content": {}, +} + +// AppsWebURL is the installed-apps administration catalog. +func AppsWebURL() string { + return "/web/apps" +} + +// ModuleWebURL returns /web/{moduleName} when the name is a valid, non-reserved module segment. +func ModuleWebURL(moduleName string) string { + moduleName = strings.TrimSpace(moduleName) + if !ValidModuleWebSegment(moduleName) { + return "" + } + return shellWebPrefix + moduleName +} + +// ValidModuleWebSegment reports whether segment is safe to use as /web/{segment}. +func ValidModuleWebSegment(segment string) bool { + segment = strings.TrimSpace(segment) + if segment == "" || !moduleNameRE.MatchString(segment) { + return false + } + _, reserved := ReservedWebSegments[strings.ToLower(segment)] + return !reserved +} + +// ParseDebugQuery interprets the debug query param: true, or true/{moduleName}. +func ParseDebugQuery(debugParam string) (enabled bool, moduleName string) { + debugParam = strings.TrimSpace(debugParam) + if debugParam == "" { + return false, "" + } + if debugParam == "true" { + return true, "" + } + const prefix = "true/" + if strings.HasPrefix(debugParam, prefix) { + mod := strings.TrimSpace(debugParam[len(prefix):]) + if ValidModuleWebSegment(mod) { + return true, mod + } + return true, mod + } + return false, "" +} + +// DebugQueryValue builds the debug query value for a module (debug=true/crm). +func DebugQueryValue(moduleName string) string { + moduleName = strings.TrimSpace(moduleName) + if moduleName == "" { + return "true" + } + return "true/" + moduleName +} + +// WebDebugQueryURL is GET /web?debug=true or /web?debug=true/{module}. +func WebDebugQueryURL(moduleName string) string { + q := DebugQueryValue(moduleName) + return WorkspaceRoute + "?debug=" + q +} + +// ModuleWebURLWithDebugAlias returns /web/{module}?debug=true (alias for module entry with debug). +func ModuleWebURLWithDebugAlias(moduleName string) string { + base := ModuleWebURL(moduleName) + if base == "" { + return "" + } + return base + "?debug=true" +} diff --git a/core/engine/templates/pages/settings_account.html b/core/engine/templates/pages/settings_account.html index f60a89c3..a58640242 100644 --- a/core/engine/templates/pages/settings_account.html +++ b/core/engine/templates/pages/settings_account.html @@ -2,7 +2,7 @@

Account security

-

Manage your password and two-factor authentication.

+

Manage your password, API keys, and two-factor authentication.

Back to Settings
@@ -24,6 +24,61 @@

Password

+ +