Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions addons/base/data/core_user_apikey_rule_data.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<sumeru>
<data noupdate="true">
<record id="rule_core_user_apikey_own" model="sys.rule">
<field name="name">Users: own API keys</field>
<field name="model">core.user.apikey</field>
<field name="domain_force">[["user_id", "=", "$uid"]]</field>
<field name="active">True</field>
<field name="perm_read">True</field>
<field name="perm_write">True</field>
<field name="perm_create">True</field>
<field name="perm_unlink">False</field>
<field name="groups">[(4, ref('base.group_user'))]</field>
</record>
</data>
</sumeru>
1 change: 1 addition & 0 deletions addons/base/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
7 changes: 7 additions & 0 deletions addons/base/views/core_user_apikey_form_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,17 @@
<group string="Key">
<field name="user_id" string="User"/>
<field name="key_prefix" string="Prefix" readonly="true"/>
<field name="expires_at" string="Expires" readonly="true"/>
<field name="last_used_at" string="Last used" readonly="true"/>
<field name="active" string="Active"/>
<field name="create_date" string="Created" readonly="true"/>
</group>
</group>
<group>
<group string="Note" colspan="2">
<label string="Generate keys from Account security or the create API key action. Secrets are never stored in plain text."/>
</group>
</group>
</sheet>
</view>
</data>
Expand Down
2 changes: 2 additions & 0 deletions addons/base/views/core_user_apikey_list_views.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
<field name="name" string="Name"/>
<field name="user_id" string="User"/>
<field name="key_prefix" string="Prefix"/>
<field name="expires_at" string="Expires"/>
<field name="last_used_at" string="Last used"/>
<field name="active" string="Active"/>
<field name="create_date" string="Created"/>
</view>
Expand Down
96 changes: 96 additions & 0 deletions core/engine/render/menu_entry.go
Original file line number Diff line number Diff line change
@@ -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)
}
23 changes: 10 additions & 13 deletions core/engine/render/menus.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,26 +89,26 @@ 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),
Sequence: int(seq.Int64),
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)
}
Expand Down Expand Up @@ -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,
Expand Down
86 changes: 86 additions & 0 deletions core/engine/render/shell_routes.go
Original file line number Diff line number Diff line change
@@ -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"
}
57 changes: 56 additions & 1 deletion core/engine/templates/pages/settings_account.html
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
<header class="sum-settings-hub-head">
<div class="sum-settings-hub-head-text">
<h1 class="sum-settings-hub-title">Account security</h1>
<p class="sum-settings-hub-lead">Manage your password and two-factor authentication.</p>
<p class="sum-settings-hub-lead">Manage your password, API keys, and two-factor authentication.</p>
</div>
<a href="/web/settings" class="sum-settings-hub-cta">Back to Settings</a>
</header>
Expand All @@ -24,6 +24,61 @@ <h2 class="sum-settings-section-title">Password</h2>
</form>
</section>

<section class="sum-settings-account-section">
<h2 class="sum-settings-section-title">API keys</h2>
<p class="sum-settings-hub-lead">Create keys for programmatic access. Each key is shown once; choose an expiration up to {{.MaxKeyDurationDays}} days.</p>
{{if .APIKeys}}
<table class="sum-settings-apikey-table">
<thead>
<tr>
<th scope="col">Name</th>
<th scope="col">Prefix</th>
<th scope="col">Expires</th>
<th scope="col">Last used</th>
<th scope="col">Status</th>
<th scope="col"></th>
</tr>
</thead>
<tbody>
{{range .APIKeys}}
<tr>
<td>{{.Name}}</td>
<td><code>{{.Prefix}}</code></td>
<td>{{.ExpiresAt}}</td>
<td>{{.LastUsedAt}}</td>
<td>{{if .Active}}Active{{else}}Revoked{{end}}</td>
<td>
{{if .Active}}
<form method="post" action="/web/settings/account" class="sum-inline-form">
<input type="hidden" name="csrf_token" value="{{$.CSRFToken}}"/>
<input type="hidden" name="action" value="revoke_api_key"/>
<input type="hidden" name="key_id" value="{{.ID}}"/>
<button type="submit" class="sum-btn sum-btn--secondary sum-btn--sm">Revoke</button>
</form>
{{end}}
</td>
</tr>
{{end}}
</tbody>
</table>
{{else}}
<p class="sum-settings-hub-lead">You have no API keys yet.</p>
{{end}}
<form class="sum-settings-account-form" method="post" action="/web/settings/account">
<input type="hidden" name="csrf_token" value="{{.CSRFToken}}"/>
<input type="hidden" name="action" value="create_api_key"/>
<div class="sum-form-group-outer sum-field-region--sheet">
<div class="sum-form-group-grid">
<label class="sum-field-label" for="api_key_name">Description</label>
<input id="api_key_name" name="api_key_name" type="text" maxlength="128" placeholder="e.g. CI pipeline" class="sum-field-input"/>
<label class="sum-field-label" for="duration_days">Valid for (days)</label>
<input id="duration_days" name="duration_days" type="number" min="1" max="{{.MaxKeyDurationDays}}" required class="sum-field-input"/>
</div>
</div>
<button type="submit" class="sum-btn sum-btn--primary">Generate API key</button>
</form>
</section>

<section class="sum-settings-account-section">
<h2 class="sum-settings-section-title">Two-factor authentication</h2>
{{if .TOTP.Enabled}}
Expand Down
Loading
Loading