-
New password
diff --git a/core/engine/templates/settings_model_acl_inner.html b/core/engine/templates/settings_model_acl_inner.html
new file mode 100644
index 00000000..4ac65aa4
--- /dev/null
+++ b/core/engine/templates/settings_model_acl_inner.html
@@ -0,0 +1,71 @@
+
+
+
+
+
+ {{if .Model}}
+ {{if .DebugAccessHref}}
+
Debug access trace for this model (requires debug mode).
+ {{end}}
+ {{if .GroupsTruncated}}
+
Showing first groups only. Show all groups
+ {{end}}
+ {{if .Cells}}
+
+ {{else}}
+
No groups to display.
+ {{end}}
+ {{end}}
+
diff --git a/core/module/addon_template/MODULE_STANDARD.txt b/core/module/addon_template/MODULE_STANDARD.txt
index 406d5e40..1f5543b1 100644
--- a/core/module/addon_template/MODULE_STANDARD.txt
+++ b/core/module/addon_template/MODULE_STANDARD.txt
@@ -110,6 +110,20 @@ ACL CSV (sys.access)
automatically during module sync. Listing the CSV in manifest data is optional and is not
parsed as XML (manifests may still include it for documentation only).
+Field access CSV (sys.field.access)
+-----------------------------------
+ Field-level ACL rows load from security/sys.field.access.csv (or sys.field.access.csv at the
+ addon root) during module sync. Header:
+ id,name,model,field_name,group_id:id,perm_read,perm_write
+ Export from Settings → Security → Field access matrix (/web/settings/field-acl) via Export CSV,
+ or from the Field Access list view CSV export with a model filter.
+
+Model access matrix (sys.access)
+--------------------------------
+ Settings → Security → Model access matrix (/web/settings/model-acl) edits CRUD grants per group
+ for one model at a time. Matrix row names use the prefix matrix. (distinct from CSV id names).
+ Model rules still load from security/sys.access.csv on module sync (see ACL CSV above).
+
Multiple addon roots (sumeru.conf)
----------------------------------
addons_path = addons,/opt/sumeru/extra-addons
diff --git a/core/module/data_sync.go b/core/module/data_sync.go
index 5072bb80..0ea97d05 100644
--- a/core/module/data_sync.go
+++ b/core/module/data_sync.go
@@ -181,12 +181,15 @@ func (addon *Addon) SyncToDB(ctx context.Context) error {
} else {
orm.InvalidateRuleCache()
}
+ if err := addon.syncCSVFieldAccess(ctx); err != nil {
+ errs = append(errs, FatalSync(moduleName, "CSV field access load", err))
+ }
var inheritQueue []parser.Record
var deferredMenus []parser.MenuItem
for _, xmlFile := range addon.Manifest.Data {
if strings.HasSuffix(strings.ToLower(strings.TrimSpace(xmlFile)), ".csv") {
- continue // ACL CSV is loaded by syncCSVModelAccess above
+ continue // ACL CSV files load via syncCSVModelAccess / syncCSVFieldAccess
}
xmlPath := filepath.Join(addon.Path, xmlFile)
if _, err := os.Stat(xmlPath); err != nil {
@@ -223,5 +226,6 @@ func (addon *Addon) SyncToDB(ctx context.Context) error {
}
}
+ ValidateFieldAccessOrphans(ctx)
return aggregateErrors(moduleName, errs)
}
diff --git a/core/module/field_access_sync.go b/core/module/field_access_sync.go
new file mode 100644
index 00000000..c8d77703
--- /dev/null
+++ b/core/module/field_access_sync.go
@@ -0,0 +1,153 @@
+package module
+
+import (
+ "context"
+ "encoding/csv"
+ "io"
+ "os"
+ "path/filepath"
+ "strings"
+
+ "sumeru/core/orm"
+ "sumeru/core/sdk/platformmsg"
+)
+
+func (addon *Addon) syncCSVFieldAccess(ctx context.Context) error {
+ csvPath := filepath.Join(addon.Path, "sys.field.access.csv")
+ if _, err := os.Stat(csvPath); err != nil {
+ csvPath = filepath.Join(addon.Path, "security", "sys.field.access.csv")
+ if _, err := os.Stat(csvPath); err != nil {
+ return nil
+ }
+ }
+
+ csvFile, err := os.Open(csvPath)
+ if err != nil {
+ return err
+ }
+ defer csvFile.Close()
+
+ csvReader := csv.NewReader(csvFile)
+ if _, err := csvReader.Read(); err != nil {
+ return err
+ }
+
+ moduleName := addon.Manifest.Name
+ for {
+ csvRecord, err := csvReader.Read()
+ if err == io.EOF {
+ break
+ }
+ if err != nil {
+ return err
+ }
+ if len(csvRecord) < 7 {
+ continue
+ }
+
+ recordXmlId := csvRecord[0]
+ accessName := strings.TrimSpace(csvRecord[1])
+ modelName := strings.TrimSpace(csvRecord[2])
+ fieldName := strings.TrimSpace(csvRecord[3])
+ groupXmlId := strings.TrimSpace(csvRecord[4])
+ permRead := csvRecord[5] == "1"
+ permWrite := csvRecord[6] == "1"
+
+ if modelName == "" || fieldName == "" {
+ syncWarn(ctx, "Warning: sys.field.access %s missing model or field_name", recordXmlId)
+ continue
+ }
+ if !registryHasField(modelName, fieldName) {
+ syncWarn(ctx, "Warning: sys.field.access %s unknown field %s.%s", recordXmlId, modelName, fieldName)
+ continue
+ }
+ if accessName == "" {
+ accessName = fieldAccessDefaultName(modelName, fieldName, groupXmlId)
+ }
+
+ var groupId int
+ if groupXmlId != "" {
+ gid, resolveErr := resolveXMLIDInModule(ctx, moduleName, groupXmlId)
+ if resolveErr != nil {
+ syncWarn(ctx, "Warning: sys.field.access %s group %q unresolved: %v", recordXmlId, groupXmlId, resolveErr)
+ }
+ groupId = gid
+ }
+
+ accessValues := map[string]interface{}{
+ "name": accessName,
+ "model": modelName,
+ "field_name": fieldName,
+ "perm_read": permRead,
+ "perm_write": permWrite,
+ }
+ if groupId > 0 {
+ accessValues["group_id"] = groupId
+ }
+
+ id, err := orm.Upsert(ctx, orm.RegistryModel("sys.field.access"), accessValues, "name")
+ if err != nil {
+ syncWarn(ctx, platformmsg.FmtGenericUpsertWarn, "sys.field.access", recordXmlId, err)
+ continue
+ }
+ if err := linkXMLRecord(ctx, moduleName, recordXmlId, "sys.field.access", id); err != nil {
+ continue
+ }
+ }
+ return nil
+}
+
+func fieldAccessDefaultName(model, field, groupXML string) string {
+ g := strings.TrimSpace(groupXML)
+ if g == "" {
+ g = "global"
+ }
+ return "field_access." + model + "." + field + "." + strings.ReplaceAll(g, ".", "_")
+}
+
+func registryHasField(modelName, fieldName string) bool {
+ inst := orm.RegistryModel(modelName)
+ if inst == nil {
+ return false
+ }
+ for _, f := range inst.Fields() {
+ if strings.TrimSpace(f.Name) == fieldName {
+ return true
+ }
+ }
+ return false
+}
+
+// ValidateFieldAccessOrphans warns about sys.field.access rows pointing at unknown models or fields.
+func ValidateFieldAccessOrphans(ctx context.Context) {
+ if orm.DB == nil {
+ return
+ }
+ if _, ok := orm.Registry["sys.field.access"]; !ok {
+ return
+ }
+ tbl := orm.MustQuotedTableName("sys.field.access")
+ rows, err := orm.DB.QueryContext(ctx, `SELECT name, model, field_name FROM `+tbl)
+ if err != nil {
+ syncWarn(ctx, "Warning: field access orphan check failed: %v", err)
+ return
+ }
+ defer rows.Close()
+ for rows.Next() {
+ var name, model, field string
+ if err := rows.Scan(&name, &model, &field); err != nil {
+ syncWarn(ctx, "Warning: field access orphan scan: %v", err)
+ return
+ }
+ model = strings.TrimSpace(model)
+ field = strings.TrimSpace(field)
+ if orm.RegistryModel(model) == nil {
+ syncWarn(ctx, "Warning: orphan sys.field.access %q model %q not registered", name, model)
+ continue
+ }
+ if !registryHasField(model, field) {
+ syncWarn(ctx, "Warning: orphan sys.field.access %q field %s.%s not on model", name, model, field)
+ }
+ }
+ _ = rows.Err()
+}
diff --git a/core/module/testexports.go b/core/module/testexports.go
index bb6d2f19..9f11bac5 100644
--- a/core/module/testexports.go
+++ b/core/module/testexports.go
@@ -25,3 +25,13 @@ func NewDataFileOptsForTest(noUpdate bool) DataFileOptsForTest {
func (o DataFileOptsForTest) SkipExistingOnUpdateForTest(ctx context.Context, moduleName, xmlID string) bool {
return dataFileOpts(o).skipExistingOnUpdate(ctx, moduleName, xmlID)
}
+
+// RegistryHasFieldForTest reports whether a model field exists in the ORM registry.
+func RegistryHasFieldForTest(modelName, fieldName string) bool {
+ return registryHasField(modelName, fieldName)
+}
+
+// FieldAccessDefaultNameForTest builds the default sys.field.access name for CSV rows.
+func FieldAccessDefaultNameForTest(model, field, groupXML string) string {
+ return fieldAccessDefaultName(model, field, groupXML)
+}
diff --git a/core/security/field_policy_labels.go b/core/security/field_policy_labels.go
new file mode 100644
index 00000000..ba3760c2
--- /dev/null
+++ b/core/security/field_policy_labels.go
@@ -0,0 +1,23 @@
+package security
+
+import "strings"
+
+// FieldKernelPolicyTags returns read-only matrix legend tags for kernel field policy.
+func FieldKernelPolicyTags(model, field string) []string {
+ model = strings.TrimSpace(model)
+ field = strings.TrimSpace(field)
+ if model == "" || field == "" {
+ return nil
+ }
+ var tags []string
+ if ReadRedactFields(model)[field] {
+ tags = append(tags, "Kernel read redact")
+ }
+ if WriteDenyDirectFields(model)[field] {
+ tags = append(tags, "Kernel write deny")
+ }
+ if WriteDenyUnlessSysFields(model)[field] {
+ tags = append(tags, "Kernel sysadmin write")
+ }
+ return tags
+}
diff --git a/core/server/web/module_tile_helpers.go b/core/server/web/module_tile_helpers.go
index 3af02a14..bab7061e 100644
--- a/core/server/web/module_tile_helpers.go
+++ b/core/server/web/module_tile_helpers.go
@@ -8,7 +8,6 @@ import (
"sumeru/core/engine/render"
"sumeru/core/module"
- "sumeru/core/orm"
)
// loadInstalledAppTiles returns installed, active application modules sorted by display name.
@@ -62,12 +61,3 @@ func loadInstalledAppTiles(ctx context.Context, forHome bool) ([]render.AppTile,
func menuHrefFromMenuID(menuID int) string {
return fmt.Sprintf("/web?menu_id=%d", menuID)
}
-
-// menuHrefFromXMLID resolves a menu XML id to a workspace URL, or "" when not found.
-func menuHrefFromXMLID(ctx context.Context, menuXMLID string) string {
- menuID, _, err := orm.ResolveXmlId(ctx, menuXMLID)
- if err != nil || menuID <= 0 {
- return ""
- }
- return menuHrefFromMenuID(menuID)
-}
diff --git a/core/server/web/query_flash.go b/core/server/web/query_flash.go
index ebd79ddf..5be979b8 100644
--- a/core/server/web/query_flash.go
+++ b/core/server/web/query_flash.go
@@ -51,6 +51,14 @@ func flashFromQueryMessage(msg string) (render.FlashMessage, bool) {
return render.FlashMessage{Kind: "error", Title: "Password required", Body: "Enter a new password."}, true
case "password_failed":
return render.FlashMessage{Kind: "error", Title: "Could not update password", Body: "Check policy requirements and try again."}, true
+ case "field_acl_saved":
+ return render.FlashMessage{Kind: "success", Title: "Field access saved", Body: "Matrix rules were updated."}, true
+ case "field_acl_failed":
+ return render.FlashMessage{Kind: "error", Title: "Field access not saved", Body: "Check the model and try again."}, true
+ case "model_acl_saved":
+ return render.FlashMessage{Kind: "success", Title: "Model access saved", Body: "Matrix rules were updated."}, true
+ case "model_acl_failed":
+ return render.FlashMessage{Kind: "error", Title: "Model access not saved", Body: "Check the model and try again."}, true
case "api_key_created":
return render.FlashMessage{Kind: "success", Title: "API key created", Body: "Copy the key from the banner above if shown."}, true
case moduleMsgSaved:
diff --git a/core/server/web/routes_table.go b/core/server/web/routes_table.go
index 7a35236d..3cc59e0b 100644
--- a/core/server/web/routes_table.go
+++ b/core/server/web/routes_table.go
@@ -96,6 +96,8 @@ func registerActionRoutes() {
func registerSettingsRoutes() {
registerSession(http.MethodGet, settingsRoute, SettingsHubHandler)
registerSettingsAccountRoutes()
+ registerSettingsFieldACLRoutes()
+ registerSettingsModelACLRoutes()
registerSession(http.MethodGet, appLogsRoute, AppLogsHandler)
registerSession(http.MethodGet, metricsRoute, MetricsHandler)
}
diff --git a/core/server/web/settings_account.go b/core/server/web/settings_account.go
index 7667f44b..4093029e 100644
--- a/core/server/web/settings_account.go
+++ b/core/server/web/settings_account.go
@@ -86,13 +86,12 @@ func buildSettingsAccountPageData(ctx context.Context, menuIDStr string, flash r
crumbs := render.BuildSettingsHubBreadcrumbs(ctx)
crumbs = append(crumbs, render.BreadcrumbItem{Label: "Account security"})
pd := render.PageData{
- Title: "Account security",
- SettingsNavActive: true,
- ActiveMenuID: menuIDStr,
- SuppressActivityDock: true,
- BreadcrumbItems: crumbs,
- ViewStylesheetURLs: []string{settingsHubStylesheetURL},
- ExtraBodyClasses: settingsHubBodyClass,
+ Title: "Account security",
+ SettingsNavActive: true,
+ ActiveMenuID: menuIDStr,
+ BreadcrumbItems: crumbs,
+ ViewStylesheetURLs: []string{settingsHubStylesheetURL},
+ ExtraBodyClasses: settingsHubBodyClass,
}
if flash.Body != "" || flash.Title != "" {
pd.FlashMessages = []render.FlashMessage{flash}
diff --git a/core/server/web/settings_acl_common.go b/core/server/web/settings_acl_common.go
new file mode 100644
index 00000000..6b28bdd3
--- /dev/null
+++ b/core/server/web/settings_acl_common.go
@@ -0,0 +1,137 @@
+package web
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "sort"
+ "strconv"
+ "strings"
+
+ "sumeru/core/engine/render"
+ "sumeru/core/orm"
+)
+
+const matrixMaxGroupsDefault = 40
+
+type aclGroupCol struct {
+ ID int
+ Label string
+}
+
+func resolveSettingsMenuXMLID(ctx context.Context, xmlID, fallbackMenuID string) string {
+ if id := render.MenuIDForXMLID(ctx, xmlID); id != "" {
+ return id
+ }
+ return strings.TrimSpace(fallbackMenuID)
+}
+
+func loadACLGroups(ctx context.Context, groupFilter string) ([]aclGroupCol, error) {
+ rows, err := orm.Search(ctx, "core.group", nil)
+ if err != nil {
+ return nil, err
+ }
+ groupFilter = strings.ToLower(strings.TrimSpace(groupFilter))
+ out := make([]aclGroupCol, 0, len(rows))
+ for _, row := range rows {
+ id := intField(row["id"])
+ if id <= 0 {
+ continue
+ }
+ label := stringField(row["name"])
+ if groupFilter != "" && !strings.Contains(strings.ToLower(label), groupFilter) {
+ continue
+ }
+ out = append(out, aclGroupCol{ID: id, Label: label})
+ }
+ sort.Slice(out, func(i, j int) bool { return out[i].Label < out[j].Label })
+ return out, nil
+}
+
+func listRegistryModelNames() []string {
+ names := make([]string, 0, len(orm.Registry))
+ for name := range orm.Registry {
+ if strings.TrimSpace(name) != "" {
+ names = append(names, name)
+ }
+ }
+ sort.Strings(names)
+ return names
+}
+
+func matrixGroupsForDisplay(all []aclGroupCol, showAll bool) ([]aclGroupCol, bool) {
+ const globalLabel = "Global"
+ cols := append([]aclGroupCol{{ID: 0, Label: globalLabel}}, all...)
+ if showAll || len(cols) <= matrixMaxGroupsDefault {
+ return cols, false
+ }
+ return cols[:matrixMaxGroupsDefault], true
+}
+
+func formHas(form map[string][]string, key string) bool {
+ vals, ok := form[key]
+ return ok && len(vals) > 0 && strings.TrimSpace(vals[0]) != ""
+}
+
+func matrixCellKey(groupID int, field string) string {
+ return fmt.Sprintf("%d_%s", groupID, field)
+}
+
+func stringField(v interface{}) string {
+ if v == nil {
+ return ""
+ }
+ switch t := v.(type) {
+ case string:
+ return strings.TrimSpace(t)
+ default:
+ return strings.TrimSpace(fmt.Sprint(t))
+ }
+}
+
+func intField(v interface{}) int {
+ switch t := v.(type) {
+ case int:
+ return t
+ case int64:
+ return int(t)
+ case float64:
+ return int(t)
+ case string:
+ n, _ := strconv.Atoi(strings.TrimSpace(t))
+ return n
+ default:
+ return 0
+ }
+}
+
+func boolField(v interface{}, defaultVal bool) bool {
+ switch t := v.(type) {
+ case bool:
+ return t
+ case string:
+ return strings.EqualFold(strings.TrimSpace(t), "true") || strings.TrimSpace(t) == "1"
+ default:
+ return defaultVal
+ }
+}
+
+func filterFieldNames(fields []string, fieldFilter string) []string {
+ fieldFilter = strings.ToLower(strings.TrimSpace(fieldFilter))
+ if fieldFilter == "" {
+ return fields
+ }
+ out := fields[:0]
+ for _, f := range fields {
+ if strings.Contains(strings.ToLower(f), fieldFilter) {
+ out = append(out, f)
+ }
+ }
+ return out
+}
+
+func writeCSVAttachment(w http.ResponseWriter, filename, body string) {
+ w.Header().Set("Content-Type", "text/csv; charset=utf-8")
+ w.Header().Set("Content-Disposition", safeContentDispositionFilename(filename))
+ _, _ = w.Write([]byte(body))
+}
diff --git a/core/server/web/settings_field_acl.go b/core/server/web/settings_field_acl.go
new file mode 100644
index 00000000..c5be0507
--- /dev/null
+++ b/core/server/web/settings_field_acl.go
@@ -0,0 +1,396 @@
+package web
+
+import (
+ "context"
+ "encoding/csv"
+ "fmt"
+ "net/http"
+ "sort"
+ "strings"
+
+ "sumeru/core/engine/render"
+ "sumeru/core/orm"
+ "sumeru/core/security"
+)
+
+const settingsFieldACLRoute = "/web/settings/field-acl"
+const settingsFieldACLExportRoute = settingsFieldACLRoute + "/export"
+
+type fieldACLMatrixCell struct {
+ FieldName string
+ GroupID int
+ GroupLabel string
+ DenyRead bool
+ DenyWrite bool
+}
+
+type fieldACLMatrixRow struct {
+ FieldName string
+ SchemaGroups bool
+ KernelTags []string
+ Cells []fieldACLMatrixCell
+}
+
+type settingsFieldACLData struct {
+ CSRFToken string
+ Model string
+ ModelChoices []string
+ GroupFilter string
+ FieldFilter string
+ ShowAllGroups bool
+ GroupsTruncated bool
+ Groups []aclGroupCol
+ Rows []fieldACLMatrixRow
+ DebugAccessHref string
+ Flash render.FlashMessage
+}
+
+func registerSettingsFieldACLRoutes() {
+ registerSession(http.MethodGet, settingsFieldACLRoute, SettingsFieldACLGetHandler)
+ registerSession(http.MethodPost, settingsFieldACLRoute, SettingsFieldACLPostHandler)
+ registerSession(http.MethodGet, settingsFieldACLExportRoute, SettingsFieldACLExportHandler)
+}
+
+func SettingsFieldACLGetHandler(w http.ResponseWriter, r *http.Request) {
+ if !requireLogin(w, r) {
+ return
+ }
+ if !requireSystemAdmin(w, r, true) {
+ return
+ }
+ ctx := r.Context()
+ rootMenuID, ok := resolveSettingsRootMenuID(w, r, ctx)
+ if !ok {
+ return
+ }
+ menuID := resolveSettingsMenuXMLID(ctx, render.MenuFieldAccessMatrixXMLID, rootMenuID)
+ q := r.URL.Query()
+ model := strings.TrimSpace(q.Get("model"))
+ flash, _ := flashFromQueryMessage(q.Get("msg"))
+ page, err := buildFieldACLMatrixPage(ctx, model, q.Get("group_q"), q.Get("field_q"), q.Get("show_all") == "1")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ page.CSRFToken = CSRFTokenForRequest(r)
+ page.Flash = flash
+ page.ModelChoices = listRegistryModelNames()
+ renderSettingsFieldACLPage(w, r, page, menuID)
+}
+
+func SettingsFieldACLExportHandler(w http.ResponseWriter, r *http.Request) {
+ if !requireLogin(w, r) {
+ return
+ }
+ if !requireSystemAdmin(w, r, true) {
+ return
+ }
+ model := strings.TrimSpace(r.URL.Query().Get("model"))
+ if model == "" || orm.RegistryModel(model) == nil {
+ http.Error(w, "model required", http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ body, err := exportFieldACLCSV(ctx, model)
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusInternalServerError)
+ return
+ }
+ writeCSVAttachment(w, "field_access_"+model+".csv", body)
+}
+
+func SettingsFieldACLPostHandler(w http.ResponseWriter, r *http.Request) {
+ if !requireLogin(w, r) {
+ return
+ }
+ if !requireSystemAdmin(w, r, true) {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ if !ValidateCSRF(r) {
+ http.Error(w, "invalid csrf", http.StatusForbidden)
+ return
+ }
+ model := strings.TrimSpace(r.PostForm.Get("model"))
+ if model == "" || orm.RegistryModel(model) == nil {
+ http.Error(w, "model required", http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ err := orm.WithElevated(ctx, "settings.field_acl_matrix", func(elevated context.Context) error {
+ groups, err := loadACLGroups(elevated, r.PostForm.Get("group_q"))
+ if err != nil {
+ return err
+ }
+ display, _ := matrixGroupsForDisplay(groups, r.PostForm.Get("show_all") == "1")
+ return applyFieldACLMatrixPost(elevated, model, r.PostForm, display)
+ })
+ if err != nil {
+ redirectWithWebMessage(w, r, fieldACLRedirectURL(model, r.PostForm), "field_acl_failed")
+ return
+ }
+ redirectWithWebMessage(w, r, fieldACLRedirectURL(model, r.PostForm), "field_acl_saved")
+}
+
+func fieldACLRedirectURL(model string, form map[string][]string) string {
+ u := settingsFieldACLRoute + "?model=" + model
+ if gq := strings.TrimSpace(firstFormVal(form, "group_q")); gq != "" {
+ u += "&group_q=" + gq
+ }
+ if fq := strings.TrimSpace(firstFormVal(form, "field_q")); fq != "" {
+ u += "&field_q=" + fq
+ }
+ if formHas(form, "show_all") {
+ u += "&show_all=1"
+ }
+ return u
+}
+
+func firstFormVal(form map[string][]string, key string) string {
+ if vals, ok := form[key]; ok && len(vals) > 0 {
+ return strings.TrimSpace(vals[0])
+ }
+ return ""
+}
+
+func applyFieldACLMatrixPost(ctx context.Context, model string, form map[string][]string, displayGroups []aclGroupCol) error {
+ groupIDs := make([]int, 0, len(displayGroups))
+ for _, g := range displayGroups {
+ groupIDs = append(groupIDs, g.ID)
+ }
+ fields := listFieldACLFields(model)
+ existing, err := loadFieldACLExisting(ctx, model)
+ if err != nil {
+ return err
+ }
+ for _, field := range fields {
+ for _, gid := range groupIDs {
+ key := matrixCellKey(gid, field)
+ denyRead := formHas(form, "dr_"+key)
+ denyWrite := formHas(form, "dw_"+key)
+ rowName := fieldACLMatrixRowName(model, field, gid)
+ if !denyRead && !denyWrite {
+ if id, ok := existing[rowName]; ok && id > 0 {
+ if err := orm.Unlink(ctx, "sys.field.access", id); err != nil {
+ return err
+ }
+ }
+ continue
+ }
+ values := map[string]interface{}{
+ "name": rowName,
+ "model": model,
+ "field_name": field,
+ "perm_read": !denyRead,
+ "perm_write": !denyWrite,
+ }
+ if gid > 0 {
+ values["group_id"] = gid
+ }
+ if _, err := orm.Upsert(ctx, orm.RegistryModel("sys.field.access"), values, "name"); err != nil {
+ return err
+ }
+ }
+ }
+ return nil
+}
+
+func fieldACLMatrixRowName(model, field string, groupID int) string {
+ if groupID <= 0 {
+ return "matrix." + model + "." + field + ".global"
+ }
+ return fmt.Sprintf("matrix.%s.%s.g%d", model, field, groupID)
+}
+
+func buildFieldACLMatrixPage(ctx context.Context, model, groupFilter, fieldFilter string, showAllGroups bool) (settingsFieldACLData, error) {
+ out := settingsFieldACLData{
+ Model: model,
+ GroupFilter: groupFilter,
+ FieldFilter: fieldFilter,
+ ShowAllGroups: showAllGroups,
+ }
+ if model == "" {
+ return out, nil
+ }
+ if orm.RegistryModel(model) == nil {
+ return out, fmt.Errorf("unknown model %q", model)
+ }
+ out.DebugAccessHref = "/web/debug/access?model=" + model
+ groups, err := loadACLGroups(ctx, groupFilter)
+ if err != nil {
+ return out, err
+ }
+ display, truncated := matrixGroupsForDisplay(groups, showAllGroups)
+ out.Groups = display
+ out.GroupsTruncated = truncated
+ existing, err := loadFieldACLExistingByCell(ctx, model)
+ if err != nil {
+ return out, err
+ }
+ fields := filterFieldNames(listFieldACLFields(model), fieldFilter)
+ for _, field := range fields {
+ row := fieldACLMatrixRow{
+ FieldName: field,
+ SchemaGroups: fieldHasGroupsAttr(model, field),
+ KernelTags: security.FieldKernelPolicyTags(model, field),
+ }
+ for _, g := range display {
+ key := matrixCellKey(g.ID, field)
+ cell := fieldACLMatrixCell{
+ FieldName: field,
+ GroupID: g.ID,
+ GroupLabel: g.Label,
+ }
+ if st, ok := existing[key]; ok {
+ cell.DenyRead = !st.read
+ cell.DenyWrite = !st.write
+ }
+ row.Cells = append(row.Cells, cell)
+ }
+ out.Rows = append(out.Rows, row)
+ }
+ return out, nil
+}
+
+type fieldACLPerm struct {
+ read bool
+ write bool
+}
+
+func loadFieldACLExistingByCell(ctx context.Context, model string) (map[string]fieldACLPerm, error) {
+ rows, err := orm.Search(ctx, "sys.field.access", [][]interface{}{{"model", "=", model}})
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]fieldACLPerm{}
+ for _, row := range rows {
+ field := stringField(row["field_name"])
+ if field == "" {
+ continue
+ }
+ gid := intField(row["group_id"])
+ key := matrixCellKey(gid, field)
+ out[key] = fieldACLPerm{
+ read: boolField(row["perm_read"], true),
+ write: boolField(row["perm_write"], true),
+ }
+ }
+ return out, nil
+}
+
+func loadFieldACLExisting(ctx context.Context, model string) (map[string]int, error) {
+ rows, err := orm.Search(ctx, "sys.field.access", [][]interface{}{{"model", "=", model}})
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]int{}
+ for _, row := range rows {
+ name := stringField(row["name"])
+ id := intField(row["id"])
+ if name != "" && id > 0 {
+ out[name] = id
+ }
+ }
+ return out, nil
+}
+
+func exportFieldACLCSV(ctx context.Context, model string) (string, error) {
+ rows, err := orm.Search(ctx, "sys.field.access", [][]interface{}{{"model", "=", model}})
+ if err != nil {
+ return "", err
+ }
+ sort.Slice(rows, func(i, j int) bool {
+ fi := stringField(rows[i]["field_name"])
+ fj := stringField(rows[j]["field_name"])
+ if fi != fj {
+ return fi < fj
+ }
+ return intField(rows[i]["group_id"]) < intField(rows[j]["group_id"])
+ })
+ var buf strings.Builder
+ w := csv.NewWriter(&buf)
+ _ = w.Write([]string{"id", "name", "model", "field_name", "group_id:id", "perm_read", "perm_write"})
+ for _, row := range rows {
+ gid := intField(row["group_id"])
+ groupRef := ""
+ if gid > 0 {
+ groupRef = fmt.Sprintf("%d", gid)
+ }
+ _ = w.Write([]string{
+ stringField(row["name"]),
+ stringField(row["name"]),
+ model,
+ stringField(row["field_name"]),
+ groupRef,
+ fmt.Sprintf("%v", boolField(row["perm_read"], true)),
+ fmt.Sprintf("%v", boolField(row["perm_write"], true)),
+ })
+ }
+ w.Flush()
+ if err := w.Error(); err != nil {
+ return "", err
+ }
+ return buf.String(), nil
+}
+
+func listFieldACLFields(model string) []string {
+ inst := orm.RegistryModel(model)
+ if inst == nil {
+ return nil
+ }
+ skip := map[string]bool{"id": true, "create_uid": true, "write_uid": true, "create_date": true, "write_date": true}
+ var names []string
+ for _, f := range inst.Fields() {
+ n := strings.TrimSpace(f.Name)
+ if n == "" || skip[n] {
+ continue
+ }
+ names = append(names, n)
+ }
+ sort.Strings(names)
+ return names
+}
+
+func fieldHasGroupsAttr(model, field string) bool {
+ inst := orm.RegistryModel(model)
+ if inst == nil {
+ return false
+ }
+ for _, f := range inst.Fields() {
+ if strings.TrimSpace(f.Name) == field {
+ return strings.TrimSpace(f.Groups) != ""
+ }
+ }
+ return false
+}
+
+func renderSettingsFieldACLPage(w http.ResponseWriter, r *http.Request, pageData settingsFieldACLData, menuIDStr string) {
+ ctx := r.Context()
+ renderShellPage(w, r, shellPageOpts{
+ Route: settingsFieldACLRoute,
+ InnerTemplate: settingsFieldACLInnerTemplate,
+ InnerData: pageData,
+ MenuIDStr: menuIDStr,
+ Page: buildSettingsFieldACLPageData(ctx, menuIDStr, pageData),
+ })
+}
+
+func buildSettingsFieldACLPageData(ctx context.Context, menuIDStr string, page settingsFieldACLData) render.PageData {
+ crumbs := render.BuildSettingsHubBreadcrumbs(ctx)
+ crumbs = append(crumbs, render.BreadcrumbItem{Label: "Field access matrix"})
+ pd := render.PageData{
+ Title: "Field access matrix",
+ SettingsNavActive: true,
+ ActiveMenuID: menuIDStr,
+ BreadcrumbItems: crumbs,
+ ViewStylesheetURLs: []string{settingsHubStylesheetURL},
+ ExtraBodyClasses: settingsHubBodyClass,
+ }
+ if page.Flash.Body != "" || page.Flash.Title != "" {
+ pd.FlashMessages = []render.FlashMessage{page.Flash}
+ }
+ return pd
+}
diff --git a/core/server/web/settings_hub.go b/core/server/web/settings_hub.go
index c6704b8c..99952fd1 100644
--- a/core/server/web/settings_hub.go
+++ b/core/server/web/settings_hub.go
@@ -3,31 +3,18 @@ package web
import (
"context"
"net/http"
- "strings"
"sumeru/core/engine/render"
"sumeru/core/orm"
)
-type settingsHubLink struct {
- Name string
- Href string
-}
-
-type settingsHubSection struct {
- Title string
- FilterText string
- Links []settingsHubLink
-}
-
-type settingsHubAppTile = render.AppTile
-
type settingsHubData struct {
- Sections []settingsHubSection
- AppTiles []settingsHubAppTile
- CompaniesMenuHref string
+ Categories []render.SettingsHubCategory
+ AppsListHref string
}
+const settingsHubScriptURL = "/static/js/settings-hub.js"
+
// SettingsHubHandler renders the Settings overview at /web/settings.
func SettingsHubHandler(w http.ResponseWriter, r *http.Request) {
if !requireLogin(w, r) {
@@ -38,27 +25,15 @@ func SettingsHubHandler(w http.ResponseWriter, r *http.Request) {
}
ctx := r.Context()
- menuIDStr, ok := resolveSettingsRootMenuID(w, r, ctx)
+ rootMenuIDStr, hubMenuIDStr, ok := resolveSettingsHubContext(w, r, ctx)
if !ok {
return
}
- sections := loadSettingsHubSections(ctx, menuIDStr)
- appTiles, err := loadInstalledAppTiles(ctx, false)
- if err != nil {
- WebLogEvent(ctx, WebLogInput{
- Route: settingsRoute, Message: "Failed to list modules for settings hub",
- Operation: "load", Status: "failure", Err: err,
- })
- http.Error(w, "Failed to list modules", http.StatusInternalServerError)
- return
- }
-
renderSettingsHubPage(w, r, settingsHubData{
- Sections: sections,
- AppTiles: appTiles,
- CompaniesMenuHref: menuHrefFromXMLID(ctx, settingsCompaniesMenuXMLID),
- }, menuIDStr)
+ Categories: render.BuildSettingsHubCategories(ctx, rootMenuIDStr),
+ AppsListHref: appsRoute,
+ }, hubMenuIDStr)
}
func requireSettingsUser(w http.ResponseWriter, r *http.Request) bool {
@@ -71,7 +46,7 @@ func requireSettingsUser(w http.ResponseWriter, r *http.Request) bool {
}
func resolveSettingsRootMenuID(w http.ResponseWriter, r *http.Request, ctx context.Context) (menuIDStr string, ok bool) {
- _, menuIDStr = resolveMenuID(ctx, settingsHubMenuXMLID)
+ _, menuIDStr = resolveMenuID(ctx, settingsRootMenuXMLID)
if menuIDStr != "" {
return menuIDStr, true
}
@@ -79,67 +54,40 @@ func resolveSettingsRootMenuID(w http.ResponseWriter, r *http.Request, ctx conte
return "", false
}
-func loadSettingsHubSections(ctx context.Context, menuIDStr string) []settingsHubSection {
- _, sidebarMenus, _, _ := render.LoadShellMenus(ctx, menuIDStr)
- sections := make([]settingsHubSection, 0, len(sidebarMenus))
- for _, sidebarSection := range sidebarMenus {
- if hubSection, include := settingsHubSectionFromSidebar(sidebarSection); include {
- sections = append(sections, hubSection)
- }
- }
- account := settingsHubSection{
- Title: "Account",
- FilterText: "account security password profile",
- Links: []settingsHubLink{{Name: "Account security", Href: settingsAccountRoute}},
- }
- return append([]settingsHubSection{account}, sections...)
-}
-
-func settingsHubSectionFromSidebar(sidebarSection render.SidebarMenu) (settingsHubSection, bool) {
- sectionTitle := strings.TrimSpace(sidebarSection.Name)
- filterTerms := []string{strings.ToLower(sectionTitle)}
- links := make([]settingsHubLink, 0, len(sidebarSection.SubMenus))
-
- for _, subMenu := range sidebarSection.SubMenus {
- linkName := strings.TrimSpace(subMenu.Name)
- linkHref := strings.TrimSpace(subMenu.Action)
- if linkName == "" || linkHref == "" {
- continue
- }
- links = append(links, settingsHubLink{Name: linkName, Href: linkHref})
- filterTerms = append(filterTerms, strings.ToLower(linkName))
+func resolveSettingsHubContext(w http.ResponseWriter, r *http.Request, ctx context.Context) (rootMenuIDStr, hubMenuIDStr string, ok bool) {
+ _, rootMenuIDStr = resolveMenuID(ctx, settingsRootMenuXMLID)
+ if rootMenuIDStr == "" {
+ http.Redirect(w, r, appsRoute, http.StatusFound)
+ return "", "", false
}
-
- if len(links) == 0 {
- return settingsHubSection{}, false
+ _, hubMenuIDStr = resolveMenuID(ctx, settingsHubMenuXMLID)
+ if hubMenuIDStr == "" {
+ hubMenuIDStr = rootMenuIDStr
}
-
- return settingsHubSection{
- Title: sectionTitle,
- FilterText: strings.Join(filterTerms, " "),
- Links: links,
- }, true
+ return rootMenuIDStr, hubMenuIDStr, true
}
-func renderSettingsHubPage(w http.ResponseWriter, r *http.Request, pageData settingsHubData, menuIDStr string) {
+func renderSettingsHubPage(w http.ResponseWriter, r *http.Request, pageData settingsHubData, hubMenuIDStr string) {
ctx := r.Context()
renderShellPage(w, r, shellPageOpts{
Route: settingsRoute,
InnerTemplate: settingsHubInnerTemplate,
InnerData: pageData,
- MenuIDStr: menuIDStr,
- Page: buildSettingsHubPageData(ctx, menuIDStr),
+ MenuIDStr: hubMenuIDStr,
+ Page: buildSettingsHubPageData(ctx, hubMenuIDStr),
})
}
-func buildSettingsHubPageData(ctx context.Context, menuIDStr string) render.PageData {
+func buildSettingsHubPageData(ctx context.Context, hubMenuIDStr string) render.PageData {
return render.PageData{
- Title: settingsHubPageTitle,
- SettingsNavActive: true,
- ActiveMenuID: menuIDStr,
- SuppressActivityDock: true,
- BreadcrumbItems: render.BuildSettingsHubBreadcrumbs(ctx),
- ViewStylesheetURLs: []string{settingsHubStylesheetURL},
- ExtraBodyClasses: settingsHubBodyClass,
+ Title: settingsHubPageTitle,
+ SettingsNavActive: true,
+ ActiveMenuID: hubMenuIDStr,
+ BreadcrumbItems: render.BuildSettingsHubBreadcrumbs(ctx),
+ ViewStylesheetURLs: []string{
+ settingsHubStylesheetURL,
+ },
+ ExtraBodyClasses: settingsHubBodyClass,
+ ExtraScriptURLs: []string{settingsHubScriptURL},
}
}
diff --git a/core/server/web/settings_model_acl.go b/core/server/web/settings_model_acl.go
new file mode 100644
index 00000000..e9dbe534
--- /dev/null
+++ b/core/server/web/settings_model_acl.go
@@ -0,0 +1,264 @@
+package web
+
+import (
+ "context"
+ "fmt"
+ "net/http"
+ "strings"
+
+ "sumeru/core/engine/render"
+ "sumeru/core/orm"
+)
+
+const settingsModelACLRoute = "/web/settings/model-acl"
+
+type modelACLMatrixCell struct {
+ GroupID int
+ GroupLabel string
+ PermRead bool
+ PermWrite bool
+ PermCreate bool
+ PermUnlink bool
+}
+
+type settingsModelACLData struct {
+ CSRFToken string
+ Model string
+ ModelChoices []string
+ GroupFilter string
+ ShowAllGroups bool
+ GroupsTruncated bool
+ Groups []aclGroupCol
+ Cells []modelACLMatrixCell
+ DebugAccessHref string
+ Flash render.FlashMessage
+}
+
+func registerSettingsModelACLRoutes() {
+ registerSession(http.MethodGet, settingsModelACLRoute, SettingsModelACLGetHandler)
+ registerSession(http.MethodPost, settingsModelACLRoute, SettingsModelACLPostHandler)
+}
+
+func SettingsModelACLGetHandler(w http.ResponseWriter, r *http.Request) {
+ if !requireLogin(w, r) {
+ return
+ }
+ if !requireSystemAdmin(w, r, true) {
+ return
+ }
+ ctx := r.Context()
+ rootMenuID, ok := resolveSettingsRootMenuID(w, r, ctx)
+ if !ok {
+ return
+ }
+ menuID := resolveSettingsMenuXMLID(ctx, render.MenuModelAccessMatrixXMLID, rootMenuID)
+ q := r.URL.Query()
+ model := strings.TrimSpace(q.Get("model"))
+ flash, _ := flashFromQueryMessage(q.Get("msg"))
+ page, err := buildModelACLMatrixPage(ctx, model, q.Get("group_q"), q.Get("show_all") == "1")
+ if err != nil {
+ http.Error(w, err.Error(), http.StatusBadRequest)
+ return
+ }
+ page.CSRFToken = CSRFTokenForRequest(r)
+ page.Flash = flash
+ page.ModelChoices = listRegistryModelNames()
+ renderSettingsModelACLPage(w, r, page, menuID)
+}
+
+func SettingsModelACLPostHandler(w http.ResponseWriter, r *http.Request) {
+ if !requireLogin(w, r) {
+ return
+ }
+ if !requireSystemAdmin(w, r, true) {
+ return
+ }
+ if err := r.ParseForm(); err != nil {
+ http.Error(w, "invalid form", http.StatusBadRequest)
+ return
+ }
+ if !ValidateCSRF(r) {
+ http.Error(w, "invalid csrf", http.StatusForbidden)
+ return
+ }
+ model := strings.TrimSpace(r.PostForm.Get("model"))
+ if model == "" || orm.RegistryModel(model) == nil {
+ http.Error(w, "model required", http.StatusBadRequest)
+ return
+ }
+ ctx := r.Context()
+ err := orm.WithElevated(ctx, "settings.model_acl_matrix", func(elevated context.Context) error {
+ groups, err := loadACLGroups(elevated, r.PostForm.Get("group_q"))
+ if err != nil {
+ return err
+ }
+ display, _ := matrixGroupsForDisplay(groups, r.PostForm.Get("show_all") == "1")
+ return applyModelACLMatrixPost(elevated, model, r.PostForm, display)
+ })
+ if err != nil {
+ redirectWithWebMessage(w, r, modelACLRedirectURL(model, r.PostForm), "model_acl_failed")
+ return
+ }
+ redirectWithWebMessage(w, r, modelACLRedirectURL(model, r.PostForm), "model_acl_saved")
+}
+
+func modelACLRedirectURL(model string, form map[string][]string) string {
+ u := settingsModelACLRoute + "?model=" + model
+ if gq := strings.TrimSpace(firstFormVal(form, "group_q")); gq != "" {
+ u += "&group_q=" + gq
+ }
+ if formHas(form, "show_all") {
+ u += "&show_all=1"
+ }
+ return u
+}
+
+func modelACLMatrixRowName(model string, groupID int) string {
+ if groupID <= 0 {
+ return "matrix." + model + ".global"
+ }
+ return fmt.Sprintf("matrix.%s.g%d", model, groupID)
+}
+
+func applyModelACLMatrixPost(ctx context.Context, model string, form map[string][]string, displayGroups []aclGroupCol) error {
+ existing, err := loadModelACLExisting(ctx, model)
+ if err != nil {
+ return err
+ }
+ for _, g := range displayGroups {
+ key := fmt.Sprintf("%d", g.ID)
+ read := formHas(form, "pr_"+key)
+ write := formHas(form, "pw_"+key)
+ create := formHas(form, "pc_"+key)
+ unlink := formHas(form, "pu_"+key)
+ rowName := modelACLMatrixRowName(model, g.ID)
+ if !read && !write && !create && !unlink {
+ if id, ok := existing[rowName]; ok && id > 0 {
+ if err := orm.Unlink(ctx, "sys.access", id); err != nil {
+ return err
+ }
+ }
+ continue
+ }
+ values := map[string]interface{}{
+ "name": rowName,
+ "model": model,
+ "perm_read": read,
+ "perm_write": write,
+ "perm_create": create,
+ "perm_unlink": unlink,
+ }
+ if g.ID > 0 {
+ values["group_id"] = g.ID
+ }
+ if _, err := orm.Upsert(ctx, orm.RegistryModel("sys.access"), values, "name"); err != nil {
+ return err
+ }
+ }
+ return nil
+}
+
+func loadModelACLExisting(ctx context.Context, model string) (map[string]int, error) {
+ rows, err := orm.Search(ctx, "sys.access", [][]interface{}{{"model", "=", model}})
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]int{}
+ for _, row := range rows {
+ name := stringField(row["name"])
+ id := intField(row["id"])
+ if name != "" && id > 0 {
+ out[name] = id
+ }
+ }
+ return out, nil
+}
+
+func buildModelACLMatrixPage(ctx context.Context, model, groupFilter string, showAllGroups bool) (settingsModelACLData, error) {
+ out := settingsModelACLData{
+ Model: model,
+ GroupFilter: groupFilter,
+ ShowAllGroups: showAllGroups,
+ }
+ if model == "" {
+ return out, nil
+ }
+ if orm.RegistryModel(model) == nil {
+ return out, fmt.Errorf("unknown model %q", model)
+ }
+ out.DebugAccessHref = "/web/debug/access?model=" + model
+ groups, err := loadACLGroups(ctx, groupFilter)
+ if err != nil {
+ return out, err
+ }
+ display, truncated := matrixGroupsForDisplay(groups, showAllGroups)
+ out.Groups = display
+ out.GroupsTruncated = truncated
+ existing, err := loadModelACLPerms(ctx, model)
+ if err != nil {
+ return out, err
+ }
+ for _, g := range display {
+ key := fmt.Sprintf("%d", g.ID)
+ cell := modelACLMatrixCell{GroupID: g.ID, GroupLabel: g.Label}
+ if st, ok := existing[key]; ok {
+ cell.PermRead = st.read
+ cell.PermWrite = st.write
+ cell.PermCreate = st.create
+ cell.PermUnlink = st.unlink
+ }
+ out.Cells = append(out.Cells, cell)
+ }
+ return out, nil
+}
+
+type modelACLPerm struct {
+ read, write, create, unlink bool
+}
+
+func loadModelACLPerms(ctx context.Context, model string) (map[string]modelACLPerm, error) {
+ rows, err := orm.Search(ctx, "sys.access", [][]interface{}{{"model", "=", model}})
+ if err != nil {
+ return nil, err
+ }
+ out := map[string]modelACLPerm{}
+ for _, row := range rows {
+ gid := intField(row["group_id"])
+ key := fmt.Sprintf("%d", gid)
+ out[key] = modelACLPerm{
+ read: boolField(row["perm_read"], false),
+ write: boolField(row["perm_write"], false),
+ create: boolField(row["perm_create"], false),
+ unlink: boolField(row["perm_unlink"], false),
+ }
+ }
+ return out, nil
+}
+
+func renderSettingsModelACLPage(w http.ResponseWriter, r *http.Request, pageData settingsModelACLData, menuIDStr string) {
+ ctx := r.Context()
+ renderShellPage(w, r, shellPageOpts{
+ Route: settingsModelACLRoute,
+ InnerTemplate: settingsModelACLInnerTemplate,
+ InnerData: pageData,
+ MenuIDStr: menuIDStr,
+ Page: buildSettingsModelACLPageData(ctx, menuIDStr, pageData),
+ })
+}
+
+func buildSettingsModelACLPageData(ctx context.Context, menuIDStr string, page settingsModelACLData) render.PageData {
+ crumbs := render.BuildSettingsHubBreadcrumbs(ctx)
+ crumbs = append(crumbs, render.BreadcrumbItem{Label: "Model access matrix"})
+ pd := render.PageData{
+ Title: "Model access matrix",
+ SettingsNavActive: true,
+ ActiveMenuID: menuIDStr,
+ BreadcrumbItems: crumbs,
+ ViewStylesheetURLs: []string{settingsHubStylesheetURL},
+ ExtraBodyClasses: settingsHubBodyClass,
+ }
+ if page.Flash.Body != "" || page.Flash.Title != "" {
+ pd.FlashMessages = []render.FlashMessage{page.Flash}
+ }
+ return pd
+}
diff --git a/core/server/web/testexports.go b/core/server/web/testexports.go
index 4918c146..08f347b2 100644
--- a/core/server/web/testexports.go
+++ b/core/server/web/testexports.go
@@ -21,7 +21,6 @@ type (
WorkspaceRequest = workspaceRequest
AppsModule = appsModule
AppsModuleGroup = appsModuleGroup
- SettingsHubSection = settingsHubSection
)
// Route and query constants for external tests.
@@ -74,6 +73,7 @@ var (
TestWorkspaceStylesheetURL = workspaceStylesheetURL
TestPagesStylesheetURL = pagesStylesheetURL
TestSettingsHubStylesheetURL = settingsHubStylesheetURL
+ TestSettingsHubScriptURL = settingsHubScriptURL
TestMaxRPCBodyBytes int64 = maxRPCBodyBytes
TestMaxChatterBodyRunes = maxChatterBodyRunes
TestSetupRateLimitWindow = setupRateLimitWindow
@@ -190,10 +190,6 @@ func ToSetupAdminParams(request SetupInitRequest) orm.SetupAdminParams {
func BuildSetupPageData() setupPageData { return buildSetupPageData() }
-func SettingsHubSectionFromSidebar(sidebarSection render.SidebarMenu) (SettingsHubSection, bool) {
- return settingsHubSectionFromSidebar(sidebarSection)
-}
-
func BuildSettingsHubPageData(ctx context.Context, menuIDStr string) render.PageData {
return buildSettingsHubPageData(ctx, menuIDStr)
}
@@ -624,3 +620,24 @@ func ParseShareTokenForTest(token string) (ShareTokenParsed, error) {
ResID: parsed.ResID,
}, nil
}
+
+func FieldACLMatrixRowNameForTest(model, field string, groupID int) string {
+ return fieldACLMatrixRowName(model, field, groupID)
+}
+
+func ModelACLMatrixRowNameForTest(model string, groupID int) string {
+ return modelACLMatrixRowName(model, groupID)
+}
+
+func MatrixGroupDisplayLenForTest(groupCount int, showAll bool) (displayLen int, truncated bool) {
+ groups := make([]aclGroupCol, groupCount)
+ for i := range groups {
+ groups[i] = aclGroupCol{ID: i + 1, Label: "g"}
+ }
+ out, truncated := matrixGroupsForDisplay(groups, showAll)
+ return len(out), truncated
+}
+
+func FilterFieldNamesForTest(fields []string, filter string) []string {
+ return filterFieldNames(fields, filter)
+}
diff --git a/core/server/web/web_constants.go b/core/server/web/web_constants.go
index eae8544e..9486b7b0 100644
--- a/core/server/web/web_constants.go
+++ b/core/server/web/web_constants.go
@@ -139,13 +139,15 @@ const (
// Settings hub page identifiers.
const (
- settingsHubMenuXMLID = "base.menu_settings_root"
- settingsCompaniesMenuXMLID = "base.menu_company_companies"
+ settingsRootMenuXMLID = "base.menu_settings_root"
+ settingsHubMenuXMLID = render.MenuGeneralSettingsXMLID
settingsHubInnerTemplate = "settings_hub_inner.html"
settingsHubPageTitle = "Settings"
settingsHubStylesheetURL = "/static/css/sumeru-settings-hub.css"
settingsHubBodyClass = " sum-body--settings-hub"
- settingsAccountInnerTemplate = "settings_account_inner.html"
+ settingsAccountInnerTemplate = "settings_account_inner.html"
+ settingsFieldACLInnerTemplate = "settings_field_acl_inner.html"
+ settingsModelACLInnerTemplate = "settings_model_acl_inner.html"
groupUserXML = "base.group_user"
)
@@ -229,9 +231,10 @@ const (
// ORM models used by workspace handlers.
const (
- sysActionWindowModel = "sys.action.window"
+ sysActionWindowModel = "sys.action.window"
sysActionURLModel = "sys.action.url"
- workspaceViewOpenOp = "view_open"
+ resConfigSettingsModel = "res.config.settings"
+ workspaceViewOpenOp = "view_open"
)
// Workspace error message fragments mapped to HTTP status codes.
diff --git a/core/server/web/workspace.go b/core/server/web/workspace.go
index 41981dad..54d539c7 100644
--- a/core/server/web/workspace.go
+++ b/core/server/web/workspace.go
@@ -41,6 +41,11 @@ func WebHandler(w http.ResponseWriter, r *http.Request) {
renderURLActionWorkspace(w, r, actionID, menuQuery)
return
}
+ menuID := CanonicalMenuID(ctx, strings.TrimSpace(menuQuery), actionID)
+ if render.MenuIDIsGeneralSettings(ctx, menuID) && actionWindowTargetModel(nav.windowData) == resConfigSettingsModel {
+ http.Redirect(w, r, settingsRoute, http.StatusFound)
+ return
+ }
actionData = nav.windowData
resolved, err = resolveWorkspaceView(ctx, r, actionData)
} else if modelQuery != "" {
diff --git a/core/swc/src/model/modifiers.ts b/core/swc/src/model/modifiers.ts
index 75371083..8790f22d 100644
--- a/core/swc/src/model/modifiers.ts
+++ b/core/swc/src/model/modifiers.ts
@@ -10,6 +10,18 @@ export interface ModifierViewContext {
context?: Record
;
}
+/** Session ids for modifier expressions (read bootstrap each render — company switch updates it). */
+export function modifierViewContext(boot: {
+ user?: { id: number };
+ activeCompanyId?: number;
+}): ModifierViewContext {
+ return {
+ userId: boot.user?.id,
+ companyId: boot.activeCompanyId,
+ context: {},
+ };
+}
+
const UNSAFE_EXPR = /[`\\[\];]|=>|\bfunction\b|\bclass\b|\bimport\b|\beval\b|\bnew\b/i;
/** Evaluate a dynamic modifier expression against allowlisted ctx keys only. */
diff --git a/core/swc/src/template/helpers.ts b/core/swc/src/template/helpers.ts
index 985cafcc..54f92a41 100644
--- a/core/swc/src/template/helpers.ts
+++ b/core/swc/src/template/helpers.ts
@@ -26,6 +26,14 @@ export function forEach(
}
/** First matching branch of a condition chain. */
+/** Shallow merge for sum-template t-call / t-set scope. */
+export function mergeScope(
+ base: Record,
+ patch: Record,
+): Record {
+ return { ...base, ...patch };
+}
+
export function when(
condition: unknown,
renderFn: () => TemplateResult,
diff --git a/core/swc/src/template/sum/codegen.ts b/core/swc/src/template/sum/codegen.ts
index 40441405..7a469093 100644
--- a/core/swc/src/template/sum/codegen.ts
+++ b/core/swc/src/template/sum/codegen.ts
@@ -1,71 +1,282 @@
-import type { SumElement, SumNode } from "./parser.js";
-import { parseSumXml } from "./parser.js";
+import type { SumDocument, SumElement, SumNode } from "./parser.js";
+import { parseSumDocument } from "./parser.js";
+import { assertSafeTemplateExpr, collectExprIdentifiers } from "./expr.js";
import { templateMeta, type TemplateSourceMeta } from "./meta.js";
function esc(s: string): string {
return s.replace(/\\/g, "\\\\").replace(/`/g, "\\`").replace(/\$/g, "\\$");
}
+function tplFnName(tName: string): string {
+ const safe = tName.replace(/\W+/g, "_");
+ return `__tpl_${safe}`;
+}
+
+function stripTemplateAttrs(attrs: Record): Record {
+ const out: Record = {};
+ for (const [k, v] of Object.entries(attrs)) {
+ if (k.startsWith("t-")) continue;
+ out[k] = v;
+ }
+ return out;
+}
+
+function dynamicAttrBindings(attrs: Record): string {
+ const staticAttrs = stripTemplateAttrs(attrs);
+ let out = "";
+ for (const [k, v] of Object.entries(attrs)) {
+ if (!k.startsWith("t-att-")) continue;
+ const attrName = k.slice("t-att-".length);
+ if (!attrName) continue;
+ assertSafeTemplateExpr(v, k);
+ const staticVal = staticAttrs[attrName];
+ delete staticAttrs[attrName];
+ if (staticVal !== undefined && staticVal !== "") {
+ if (attrName === "class") {
+ out += ` class=\${${JSON.stringify(`${staticVal} `)} + String(${v})}`;
+ continue;
+ }
+ }
+ out += ` ${attrName}=\${${v}}`;
+ }
+ return out;
+}
+
function attrToHtml(attrs: Record): string {
- return Object.entries(attrs)
- .filter(([k]) => !k.startsWith("t-"))
+ const bindings = dynamicAttrBindings(attrs);
+ const staticOnly = { ...stripTemplateAttrs(attrs) };
+ for (const k of Object.keys(attrs)) {
+ if (k.startsWith("t-att-")) {
+ const attrName = k.slice("t-att-".length);
+ delete staticOnly[attrName];
+ }
+ }
+ const staticStr = Object.entries(staticOnly)
.map(([k, v]) => (v ? `${k}="${esc(v)}"` : k))
.join(" ");
+ return staticStr + bindings;
}
-function elementBlock(el: SumElement, ctx: string): string {
+function collectTemplateIdentifiers(root: SumElement): Set {
+ const ids = new Set();
+ const noteExpr = (expr: string): void => {
+ for (const id of collectExprIdentifiers(expr)) {
+ ids.add(id);
+ }
+ };
+
+ const walk = (nodes: SumNode[]): void => {
+ for (const node of nodes) {
+ if (node.type === "text") continue;
+ if (node.type === "interpolation") {
+ noteExpr(node.expr);
+ continue;
+ }
+ const el = node;
+ for (const [key, raw] of Object.entries(el.attrs)) {
+ if (key === "t-else") continue;
+ if (key === "t-foreach") {
+ const m = raw.match(/^\w+\s+in\s+(.+)$/);
+ if (m) noteExpr(m[1]);
+ continue;
+ }
+ if (
+ key.startsWith("t-att-") ||
+ key === "t-if" ||
+ key === "t-elif" ||
+ key === "t-esc" ||
+ key === "t-raw" ||
+ key === "t-out" ||
+ key === "t-value" ||
+ key === "t-context"
+ ) {
+ noteExpr(raw);
+ }
+ }
+ walk(el.children);
+ }
+ };
+
+ if (root.children.length > 0) {
+ walk(root.children);
+ } else {
+ walk([root]);
+ }
+ return ids;
+}
+
+function destructurePrelude(ids: Set): string {
+ if (ids.size === 0) return "";
+ const lines = [...ids].sort().map((id) => ` const ${id} = scope[${JSON.stringify(id)}];`);
+ return `${lines.join("\n")}\n `;
+}
+
+function assertNoCallCycles(templates: Map): void {
+ for (const [name, el] of templates) {
+ const stack = [name];
+ const walk = (node: SumElement, chain: string[]): void => {
+ const call = node.attrs["t-call"]?.trim();
+ if (call) {
+ if (call === name || chain.includes(call)) {
+ throw new Error(`sum-template: recursive t-call: ${[...chain, call].join(" -> ")}`);
+ }
+ const target = templates.get(call);
+ if (target) walk(target, [...chain, call]);
+ }
+ for (const child of node.children) {
+ if (child.type === "element") walk(child, chain);
+ }
+ };
+ walk(el, stack);
+ }
+}
+
+function elementBlock(el: SumElement, scopeVar: string, templates: Map): string {
const attrs = { ...el.attrs };
delete attrs["t-if"];
delete attrs["t-elif"];
delete attrs["t-else"];
delete attrs["t-foreach"];
delete attrs["t-key"];
- return `html\`<${el.tag} ${attrToHtml(attrs)}>${codegenChildren(el.children, ctx)}${el.tag}>\``;
+ return `html\`<${el.tag} ${attrToHtml(attrs)}>${codegenChildren(el.children, scopeVar, templates)}${el.tag}>\``;
}
-function codegenChildren(nodes: SumNode[], ctx: string): string {
+/** Avoid nested html` inside html` when t-set wraps a single ${expr} interpolation. */
+function wrapSetterBinding(name: string, expr: string, chunk: string): string {
+ const trimmed = chunk.trim();
+ if (trimmed.startsWith("${") && trimmed.endsWith("}")) {
+ return `((${name}) => ${trimmed.slice(2, -1)})(${expr})`;
+ }
+ return `((${name}) => html\`${chunk}\`)(${expr})`;
+}
+
+function codegenChildren(nodes: SumNode[], scopeVar: string, templates: Map): string {
let out = "";
let i = 0;
while (i < nodes.length) {
- const node = nodes[i];
- if (node.type === "element" && node.attrs["t-if"]) {
- const branches: SumElement[] = [node];
- i += 1;
- while (i < nodes.length) {
- const next = nodes[i];
- if (next.type !== "element") break;
- if ("t-elif" in next.attrs) {
- branches.push(next);
- i += 1;
- continue;
+ const setters: { name: string; expr: string }[] = [];
+ while (i < nodes.length) {
+ const node = nodes[i];
+ if (node.type !== "element" || !node.attrs["t-set"]) break;
+ const varName = node.attrs["t-set"].trim();
+ if (!varName) throw new Error("sum-template: t-set requires a variable name");
+ let expr = node.attrs["t-value"]?.trim();
+ if (!expr) {
+ const textOnly = node.children.length === 1 && node.children[0].type === "text";
+ if (textOnly) {
+ expr = JSON.stringify((node.children[0] as { value: string }).value);
+ } else if (node.children.length === 0) {
+ throw new Error(`sum-template: t-set ${varName} requires t-value or text body`);
+ } else {
+ throw new Error(`sum-template: t-set ${varName} with nested elements is not supported`);
}
- if ("t-else" in next.attrs) {
- branches.push(next);
- i += 1;
- }
- break;
+ } else {
+ assertSafeTemplateExpr(expr, `t-set ${varName}`);
+ }
+ setters.push({ name: varName, expr });
+ i += 1;
+ }
+
+ if (setters.length > 0) {
+ let chunk = codegenChildrenWithoutSetPrefix(nodes.slice(i), scopeVar, templates);
+ for (let j = setters.length - 1; j >= 0; j--) {
+ const { name, expr } = setters[j];
+ chunk = wrapSetterBinding(name, expr, chunk);
}
- const first = branches[0];
- const elifs = branches.slice(1).map((branch) => {
- const cond = "t-else" in branch.attrs ? "true" : branch.attrs["t-elif"];
- return `[${cond}, () => ${elementBlock(branch, ctx)}]`;
- });
- out += `\${when(${first.attrs["t-if"]}, () => ${elementBlock(first, ctx)}${
- elifs.length ? `, ${elifs.join(", ")}` : ""
- })}`;
- continue;
+ out += `\${${chunk}}`;
+ break;
}
- out += codegenNode(node, ctx);
- i += 1;
+
+ const piece = codegenSingleSibling(nodes, i, scopeVar, templates);
+ out += piece.out;
+ i = piece.next;
}
return out;
}
-function codegenNode(node: SumNode, ctx: string): string {
+/** Siblings only — no leading t-set prefix (used inside t-set scope blocks). */
+function codegenChildrenWithoutSetPrefix(
+ nodes: SumNode[],
+ scopeVar: string,
+ templates: Map,
+): string {
+ let out = "";
+ let i = 0;
+ while (i < nodes.length) {
+ const piece = codegenSingleSibling(nodes, i, scopeVar, templates);
+ out += piece.out;
+ i = piece.next;
+ }
+ return out;
+}
+
+function codegenSingleSibling(
+ nodes: SumNode[],
+ i: number,
+ scopeVar: string,
+ templates: Map,
+): { out: string; next: number } {
+ if (i >= nodes.length) return { out: "", next: i };
+ const node = nodes[i];
+ if (node.type === "element" && node.attrs["t-if"]) {
+ const branches: SumElement[] = [node];
+ let next = i + 1;
+ while (next < nodes.length) {
+ const candidate = nodes[next];
+ if (candidate.type !== "element") break;
+ if ("t-elif" in candidate.attrs) {
+ branches.push(candidate);
+ next += 1;
+ continue;
+ }
+ if ("t-else" in candidate.attrs) {
+ branches.push(candidate);
+ next += 1;
+ }
+ break;
+ }
+ const first = branches[0];
+ assertSafeTemplateExpr(first.attrs["t-if"], "t-if");
+ const elifs = branches.slice(1).map((branch) => {
+ const cond = "t-else" in branch.attrs ? "true" : branch.attrs["t-elif"];
+ if (cond !== "true") assertSafeTemplateExpr(cond, "t-elif");
+ return `[${cond}, () => ${elementBlock(branch, scopeVar, templates)}]`;
+ });
+ const out = `\${when(${first.attrs["t-if"]}, () => ${elementBlock(first, scopeVar, templates)}${
+ elifs.length ? `, ${elifs.join(", ")}` : ""
+ })}`;
+ return { out, next };
+ }
+ const out = codegenNode(node, scopeVar, templates);
+ return { out, next: i + 1 };
+}
+
+function codegenNode(node: SumNode, scopeVar: string, templates: Map): string {
if (node.type === "text") return esc(node.value);
- if (node.type === "interpolation") return `\${${node.expr}}`;
+ if (node.type === "interpolation") {
+ assertSafeTemplateExpr(node.expr, "interpolation");
+ return `\${${node.expr}}`;
+ }
const el = node;
+ const tCall = el.attrs["t-call"]?.trim();
+ if (tCall) {
+ if (!templates.has(tCall)) {
+ throw new Error(`sum-template: unknown t-call ${tCall}`);
+ }
+ const tContext = el.attrs["t-context"]?.trim();
+ let callScope = scopeVar;
+ if (tContext) {
+ assertSafeTemplateExpr(tContext, "t-context");
+ callScope = `mergeScope(${scopeVar}, ${tContext})`;
+ }
+ return `\${${tplFnName(tCall)}(${callScope}, env)}`;
+ }
+
+ if (el.attrs["t-set"]) {
+ return codegenChildren([el], scopeVar, templates);
+ }
+
const tForeach = el.attrs["t-foreach"];
const tKey = el.attrs["t-key"];
const tEsc = el.attrs["t-esc"];
@@ -80,21 +291,28 @@ function codegenNode(node: SumNode, ctx: string): string {
const m = tForeach.match(/^(\w+)\s+in\s+(.+)$/);
if (!m) throw new Error(`Invalid t-foreach: ${tForeach}`);
const [, item, collection] = m;
+ assertSafeTemplateExpr(collection, "t-foreach");
const keyExpr = tKey ? tKey.replace(item, `${item}`) : `\`${item}\``;
- const inner = codegenChildren(el.children, ctx);
+ const inner = codegenChildren(el.children, scopeVar, templates);
return `\${forEach(${collection}, (${item}) => ${keyExpr}, (${item}) => html\`<${el.tag} ${attrToHtml(el.attrs)}>${inner}${el.tag}>\`)}`;
}
if (el.attrs["t-if"]) {
- return codegenChildren([el], ctx);
+ return codegenChildren([el], scopeVar, templates);
}
if (tComponent) {
- return `\${mountComponent(${tComponent}, { ...${ctx} }, env)}`;
+ return `\${mountComponent(${tComponent}, { ...${scopeVar} }, env)}`;
}
- if (tEsc) return `\${${tEsc}}`;
- if (tRaw) return `\${${tRaw}}`;
+ if (tEsc) {
+ assertSafeTemplateExpr(tEsc, "t-esc");
+ return `\${${tEsc}}`;
+ }
+ if (tRaw) {
+ assertSafeTemplateExpr(tRaw, "t-raw");
+ return `\${${tRaw}}`;
+ }
const attrs = { ...el.attrs };
delete attrs["t-ref"];
@@ -112,27 +330,50 @@ function codegenNode(node: SumNode, ctx: string): string {
if (tPortal) attrStr += ` data-portal="${esc(tPortal)}"`;
if (tSlot) attrStr += ` data-slot="${esc(tSlot)}"`;
- const inner = codegenChildren(el.children, ctx);
+ const inner = codegenChildren(el.children, scopeVar, templates);
const voidTag = ["img", "br", "hr", "input", "meta", "link"].includes(el.tag.toLowerCase());
if (voidTag) return `<${el.tag} ${attrStr} />`;
return `<${el.tag} ${attrStr}>${inner}${el.tag}>`;
}
+function codegenTemplateBody(
+ template: SumElement,
+ scopeVar: string,
+ templates: Map,
+): string {
+ const body = codegenChildren(template.children.length ? template.children : [template], scopeVar, templates);
+ return template.tag === "t" ? body : codegenNode(template, scopeVar, templates);
+}
+
export interface CodegenResult {
code: string;
meta: TemplateSourceMeta;
}
-export function codegen(template: SumElement, componentName: string, file: string): CodegenResult {
- const body = codegenChildren(template.children.length ? template.children : [template], "props");
- const wrapped = template.tag === "t" ? body : codegenNode(template, "props");
+export function codegen(doc: SumDocument, componentName: string, file: string): CodegenResult {
+ assertNoCallCycles(doc.templates);
+
+ const tplFns: string[] = [];
+ for (const [tName, el] of doc.templates) {
+ const fn = tplFnName(tName);
+ const body = codegenTemplateBody(el, "scope", doc.templates);
+ const ids = collectTemplateIdentifiers(el);
+ const prelude = destructurePrelude(ids);
+ tplFns.push(`function ${fn}(scope: Record, env: SwcEnv) {\n${prelude}return html\`${body}\`;\n}`);
+ }
+
+ const mainBody = codegenTemplateBody(doc.main, "props", doc.templates);
+ const wrapped = doc.main.tag === "t" ? mainBody : codegenNode(doc.main, "props", doc.templates);
+
const code = `import { html } from "../../template/html.js";
-import { forEach, when } from "../../template/helpers.js";
+import { forEach, when, mergeScope } from "../../template/helpers.js";
import { mountComponent } from "../../runtime/component-host.js";
import { inputValueFromEvent } from "../../widgets/field-events.js";
import type { SwcEnv } from "../../runtime/env.js";
+${tplFns.join("\n\n")}
+
export function template(props: Record, env: SwcEnv) {
return html\`${wrapped}\`;
}
@@ -144,5 +385,5 @@ export function template(props: Record, env: SwcEnv) {
}
export function compileSumXml(source: string, componentName: string, file: string): CodegenResult {
- return codegen(parseSumXml(source), componentName, file);
+ return codegen(parseSumDocument(source), componentName, file);
}
diff --git a/core/swc/src/template/sum/expr.ts b/core/swc/src/template/sum/expr.ts
new file mode 100644
index 00000000..004e2328
--- /dev/null
+++ b/core/swc/src/template/sum/expr.ts
@@ -0,0 +1,42 @@
+/** Reject template expressions that could escape the props scope. */
+const UNSAFE_TEMPLATE_EXPR = /[`\\[\];]|=>|\bfunction\b|\bclass\b|\bimport\b|\beval\b|\bnew\b/i;
+
+const EXPR_KEYWORDS = new Set([
+ "true",
+ "false",
+ "null",
+ "undefined",
+ "props",
+ "scope",
+ "mergeScope",
+ "env",
+ "when",
+ "forEach",
+ "String",
+ "Number",
+ "Boolean",
+]);
+
+/** Bare identifiers referenced in a template expression (for sub-template destructuring). */
+export function collectExprIdentifiers(expr: string): string[] {
+ const ids: string[] = [];
+ const stripped = expr.replace(/'[^']*'|"[^"]*"/g, " ");
+ const re = /\b([A-Za-z_]\w*)\b/g;
+ let m: RegExpExecArray | null;
+ while ((m = re.exec(stripped))) {
+ if (!EXPR_KEYWORDS.has(m[1])) {
+ ids.push(m[1]);
+ }
+ }
+ return ids;
+}
+
+export function assertSafeTemplateExpr(expr: string, where: string): void {
+ const trimmed = expr.trim();
+ if (!trimmed) {
+ throw new Error(`sum-template: empty expression in ${where}`);
+ }
+ if (UNSAFE_TEMPLATE_EXPR.test(trimmed)) {
+ throw new Error(`sum-template: unsafe expression in ${where}: ${trimmed}`);
+ }
+}
diff --git a/core/swc/src/template/sum/parser.ts b/core/swc/src/template/sum/parser.ts
index 764ea1d4..5023c04c 100644
--- a/core/swc/src/template/sum/parser.ts
+++ b/core/swc/src/template/sum/parser.ts
@@ -21,9 +21,14 @@ export interface SumInterpolation {
export type SumNode = SumElement | SumText | SumInterpolation;
+export interface SumDocument {
+ templates: Map;
+ main: SumElement;
+}
+
const VOID = new Set(["br", "hr", "img", "input", "meta", "link"]);
-export function parseSumXml(source: string): SumElement {
+function parseSumRoot(source: string): SumElement {
const root: SumElement = { type: "element", tag: "t", attrs: {}, children: [] };
const stack: SumElement[] = [root];
let i = 0;
@@ -81,7 +86,8 @@ export function parseSumXml(source: string): SumElement {
const el: SumElement = { type: "element", tag, attrs: parseAttrs(attrRaw), children: [] };
stack[stack.length - 1].children.push(el);
- if (!inner.endsWith("/") && !VOID.has(tag.toLowerCase())) {
+ const isSelfClosing = selfClose !== -1 && selfClose < close;
+ if (!isSelfClosing && !VOID.has(tag.toLowerCase())) {
stack.push(el);
}
continue;
@@ -103,7 +109,37 @@ export function parseSumXml(source: string): SumElement {
}
}
- const template = root.children.find((c) => c.type === "element") as SumElement | undefined;
- if (!template) throw new Error("sum-template: missing root element");
- return template;
+ return root;
+}
+
+export function parseSumDocument(source: string): SumDocument {
+ const root = parseSumRoot(source);
+ const templates = new Map();
+ let main: SumElement | undefined;
+
+ for (const child of root.children) {
+ if (child.type !== "element") continue;
+ const tName = child.attrs["t-name"]?.trim();
+ if (tName) {
+ if (templates.has(tName)) {
+ throw new Error(`sum-template: duplicate t-name ${tName}`);
+ }
+ templates.set(tName, child);
+ continue;
+ }
+ if (main) {
+ throw new Error("sum-template: multiple root templates (use t-name for extras)");
+ }
+ main = child;
+ }
+
+ if (!main) {
+ throw new Error("sum-template: missing root element");
+ }
+ return { templates, main };
+}
+
+/** @deprecated Use parseSumDocument; returns main template only. */
+export function parseSumXml(source: string): SumElement {
+ return parseSumDocument(source).main;
}
diff --git a/core/swc/src/views/form/FormView.ts b/core/swc/src/views/form/FormView.ts
index 2e6c800d..4013da1f 100644
--- a/core/swc/src/views/form/FormView.ts
+++ b/core/swc/src/views/form/FormView.ts
@@ -12,7 +12,7 @@ import { initFormInteractions } from "./form-interactions.js";
import { validatePasswordMatchGroups } from "../../widgets/password-match.js";
import { FieldHost } from "../../widgets/field-host.js";
import { ChatterPanel } from "../chatter/ChatterPanel.js";
-import { isFieldVisible } from "../../model/modifiers.js";
+import { isFieldVisible, modifierViewContext, resolveFieldModifiers } from "../../model/modifiers.js";
import { VIEW_FORM, VIEW_LIST } from "../../constants/routes.js";
import { runObjectAction } from "../shared/object-action.js";
import {
@@ -117,7 +117,7 @@ export class FormView extends SwcComponent {
record: SwcRecord,
readonly: boolean,
): HTMLElement => {
- if (!isFieldVisible(field, record)) {
+ if (!isFieldVisible(field, record, modifierViewContext(this.env.bootstrap))) {
const element = document.createElement("div");
element.hidden = true;
return element;
@@ -200,7 +200,11 @@ export class FormView extends SwcComponent {
this.error = "";
this.rerender();
try {
- const required = this.fields().filter((f) => f.required).map((f) => f.name);
+ const viewCtx = modifierViewContext(this.env.bootstrap);
+ const required = this.fields()
+ .filter((f) => isFieldVisible(f, this.record, viewCtx))
+ .filter((f) => resolveFieldModifiers(f, this.record, viewCtx).required)
+ .map((f) => f.name);
this.env.services.record.validate(this.record, required);
const isNew = payload.recordId <= 0;
const id = await this.env.services.record.save(this.record);
@@ -381,6 +385,7 @@ export class FormView extends SwcComponent {
onDuplicate: () => void this.duplicateRecord(),
onObjectButton: (btn) => void this.runObjectButton(btn),
renderField: this.renderFieldCached,
+ viewCtx: modifierViewContext(this.env.bootstrap),
reportsSlot: renderReportsAnchor({
open: this.reportsPanelOpen,
onToggle: () => {
diff --git a/core/swc/src/views/form/form-chrome.ts b/core/swc/src/views/form/form-chrome.ts
index de282d60..7ed2c690 100644
--- a/core/swc/src/views/form/form-chrome.ts
+++ b/core/swc/src/views/form/form-chrome.ts
@@ -1,7 +1,7 @@
import { html, type TemplateResult, type TemplateValue } from "../../template/html.js";
import type { SwcArchButton, SwcArchField, SwcWorkspacePayload } from "../../types/workspace.js";
import type { SwcRecord } from "../../model/record.js";
-import { isButtonVisible } from "../../model/modifiers.js";
+import { isButtonVisible, type ModifierViewContext } from "../../model/modifiers.js";
import { headerButton, renderNewButton } from "../shared/view-toolbar.js";
export interface FormToolbarOptions {
@@ -21,6 +21,7 @@ export interface FormToolbarOptions {
renderField: (field: SwcArchField, record: SwcRecord, readonly: boolean) => HTMLElement;
reportsSlot?: TemplateValue;
onToolbarBackdropClick?: () => void;
+ viewCtx?: ModifierViewContext;
}
/** Primary Save/Cancel/Edit/New/object buttons for the form record toolbar. */
@@ -59,7 +60,7 @@ export function renderFormToolbarPrimary(options: FormToolbarOptions): HTMLEleme
for (const archButton of headerButtons) {
if (archButton.type !== "object") continue;
if (payload.recordId <= 0) continue;
- if (!isButtonVisible(archButton, record)) continue;
+ if (!isButtonVisible(archButton, record, options.viewCtx)) continue;
items.push(
headerButton(
archButton.string || archButton.name,
diff --git a/core/swc/src/views/list/ListView.ts b/core/swc/src/views/list/ListView.ts
index 19542223..866f473d 100644
--- a/core/swc/src/views/list/ListView.ts
+++ b/core/swc/src/views/list/ListView.ts
@@ -14,7 +14,7 @@ import { VIEW_LIST } from "../../constants/routes.js";
import { runObjectAction } from "../shared/object-action.js";
import { formatFieldValue } from "../shared/field-display.js";
import { listColumns } from "../shared/arch-fields.js";
-import { isFieldVisible } from "../../model/modifiers.js";
+import { isFieldVisible, modifierViewContext } from "../../model/modifiers.js";
import { navigateCollectionQuery, parseFilterCSV } from "../shared/collection-query.js";
import { CollectionView } from "../shared/collection-view.js";
import { openWorkspaceRecord } from "../shared/collection-navigation.js";
@@ -86,7 +86,9 @@ export class ListView extends CollectionView {
}
private columns() {
- return listColumns(this.props.payload.arch).filter((col) => isFieldVisible(col));
+ return listColumns(this.props.payload.arch).filter((col) =>
+ isFieldVisible(col, undefined, modifierViewContext(this.env.bootstrap)),
+ );
}
private pageRows() {
diff --git a/core/swc/tests/model/modifiers.test.ts b/core/swc/tests/model/modifiers.test.ts
index f6e1030b..049fda13 100644
--- a/core/swc/tests/model/modifiers.test.ts
+++ b/core/swc/tests/model/modifiers.test.ts
@@ -76,4 +76,14 @@ describe("field modifiers", () => {
const record = new SwcRecord("m", 1, {});
expect(evalModifierExpr("function(){return true}", record)).toBeUndefined();
});
+
+ it("evalModifierExpr uses user_id and company_id from view context", () => {
+ const record = new SwcRecord("m", 1, {});
+ expect(
+ evalModifierExpr("user_id == 5", record, { userId: 5, companyId: 1 }),
+ ).toBe(true);
+ expect(
+ evalModifierExpr("company_id == 3", record, { userId: 1, companyId: 3 }),
+ ).toBe(true);
+ });
});
diff --git a/core/swc/tests/template/fixtures/composition.sum.xml b/core/swc/tests/template/fixtures/composition.sum.xml
new file mode 100644
index 00000000..f0859ce2
--- /dev/null
+++ b/core/swc/tests/template/fixtures/composition.sum.xml
@@ -0,0 +1,5 @@
+{{ label }}
+
+
+
+
diff --git a/core/swc/tests/template/sum-compiler.test.ts b/core/swc/tests/template/sum-compiler.test.ts
index f53b5d8e..2fc63fad 100644
--- a/core/swc/tests/template/sum-compiler.test.ts
+++ b/core/swc/tests/template/sum-compiler.test.ts
@@ -3,9 +3,29 @@ import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
import { compileSumXml } from "../../src/template/sum/codegen.js";
+import { html } from "../../src/template/html.js";
+import { forEach, mergeScope, when } from "../../src/template/helpers.js";
+import type { SwcEnv } from "../../src/runtime/env.js";
const fixtureDir = dirname(fileURLToPath(import.meta.url));
+function runCompiledTemplate(code: string) {
+ let body = code.replace(/^import .*;\n/gm, "");
+ body = body
+ .replace(/: Record/g, "")
+ .replace(/: SwcEnv/g, "")
+ .replace(/\bexport function template\b/, "function template");
+ return new Function(
+ "html",
+ "mergeScope",
+ "forEach",
+ "when",
+ "mountComponent",
+ "inputValueFromEvent",
+ body + "\nreturn template;",
+ ) as (...args: unknown[]) => (props: Record, env: SwcEnv) => ReturnType;
+}
+
describe("sum-template compiler", () => {
it("compiles t-foreach and t-if", () => {
const xml = `{{ item.name }}
`;
@@ -24,4 +44,59 @@ describe("sum-template compiler", () => {
expect(code).toContain("${markup}");
expect(code).toContain("inputValueFromEvent");
});
+
+ it("compiles t-name, t-call, t-set, and t-att-*", () => {
+ const source = readFileSync(join(fixtureDir, "fixtures/composition.sum.xml"), "utf8");
+ const { code } = compileSumXml(source, "Composition", "composition.sum.xml");
+ expect(code).toContain("function __tpl_chip");
+ expect(code).toContain("mergeScope");
+ expect(code).toContain("const kind = scope");
+ expect(code).toContain('"chip "');
+ expect(code).toContain("__tpl_chip");
+ });
+
+ it("runs composition fixture at runtime", () => {
+ const source = readFileSync(join(fixtureDir, "fixtures/composition.sum.xml"), "utf8");
+ const { code } = compileSumXml(source, "Composition", "composition.sum.xml");
+ const mountComponent = () => html``;
+ const inputValueFromEvent = () => "";
+ const env = {} as SwcEnv;
+ const template = runCompiledTemplate(code)(html, mergeScope, forEach, when, mountComponent, inputValueFromEvent);
+ const root = template({ title: "Hello" }, env).render();
+ const span = root.querySelector("span.chip");
+ expect(span?.className).toBe("chip primary");
+ expect(span?.textContent).toBe("Hello");
+ });
+
+ it("t-set applies to all following siblings", () => {
+ const xml = `{{ x }}{{ x }}
`;
+ const { code } = compileSumXml(xml, "SetScope", "set.sum.xml");
+ const mountComponent = () => html``;
+ const inputValueFromEvent = () => "";
+ const env = {} as SwcEnv;
+ const template = runCompiledTemplate(code)(html, mergeScope, forEach, when, mountComponent, inputValueFromEvent);
+ const root = template({}, env).render();
+ expect(root.querySelector("span")?.textContent).toBe("ok");
+ expect(root.querySelector("em")?.textContent).toBe("ok");
+ });
+
+ it("rejects duplicate t-name", () => {
+ const xml = ``;
+ expect(() => compileSumXml(xml, "Dup", "dup.sum.xml")).toThrow(/duplicate t-name/);
+ });
+
+ it("rejects unknown t-call", () => {
+ const xml = `
`;
+ expect(() => compileSumXml(xml, "Bad", "bad.sum.xml")).toThrow(/unknown t-call/);
+ });
+
+ it("rejects empty t-value on t-set", () => {
+ const xml = `
`;
+ expect(() => compileSumXml(xml, "Bad", "bad.sum.xml")).toThrow(/t-value/);
+ });
+
+ it("rejects recursive t-call between named templates", () => {
+ const xml = ``;
+ expect(() => compileSumXml(xml, "Cycle", "cycle.sum.xml")).toThrow(/recursive t-call/);
+ });
});
diff --git a/test/core/engine/render/menus_settings_test.go b/test/core/engine/render/menus_settings_test.go
index 3d1f9afc..1d4b0e03 100644
--- a/test/core/engine/render/menus_settings_test.go
+++ b/test/core/engine/render/menus_settings_test.go
@@ -46,6 +46,95 @@ func TestBuildSidebarMenus_localizationSection(t *testing.T) {
}
}
+func TestBuildSidebarMenus_companiesConfigurationFlatten(t *testing.T) {
+ allMenus := []parser.MenuItem{
+ {ID: "100", Name: "Settings", Sequence: 5},
+ {ID: "110", Name: "Companies", ParentID: "100", Sequence: 10},
+ {ID: "111", Name: "All Companies", ParentID: "110", Sequence: 10, Action: "/web?action=1&menu_id=111"},
+ {ID: "112", Name: "Configuration", ParentID: "110", Sequence: 90},
+ {ID: "113", Name: "Access Rights", ParentID: "112", Sequence: 16, Action: "/web?action=2&menu_id=113"},
+ {ID: "114", Name: "Field access matrix", ParentID: "112", Sequence: 19, Action: "/web?action=3&menu_id=114"},
+ }
+ menuAllowed := func(parser.MenuItem) bool { return true }
+ sections := render.BuildSidebarMenus(allMenus, "100", menuAllowed)
+ var companies *render.SidebarMenu
+ for i := range sections {
+ if sections[i].Name == "Companies" {
+ companies = §ions[i]
+ break
+ }
+ }
+ if companies == nil {
+ t.Fatal("Companies section not found")
+ }
+ names := map[string]bool{}
+ for _, sm := range companies.SubMenus {
+ names[sm.Name] = true
+ }
+ if names["Configuration"] {
+ t.Fatal("Configuration container should not appear as a link")
+ }
+ for _, want := range []string{"All Companies", "Access Rights", "Field access matrix"} {
+ if !names[want] {
+ t.Fatalf("missing flattened link %q; got %v", want, names)
+ }
+ }
+}
+
+func TestApplySettingsNavHrefOverridesForTest_staleWindowAction(t *testing.T) {
+ allMenus := []parser.MenuItem{
+ {ID: "100", Name: "Settings", Sequence: 5},
+ {ID: "101", Name: "General Settings", ParentID: "100", Sequence: 1, Action: "/web?action=99&menu_id=101"},
+ }
+ render.ApplySettingsNavHrefOverridesForTest(allMenus, "101")
+ if allMenus[1].Action != render.SettingsHubPath {
+ t.Fatalf("Action = %q; want %q", allMenus[1].Action, render.SettingsHubPath)
+ }
+ sections := render.BuildSidebarMenus(allMenus, "100", func(parser.MenuItem) bool { return true })
+ if len(sections) != 1 || sections[0].SubMenus[0].Action != render.SettingsHubPath {
+ t.Fatalf("sidebar after override: %+v", sections)
+ }
+}
+
+func TestBuildSidebarMenus_generalSettingsSection(t *testing.T) {
+ allMenus := []parser.MenuItem{
+ {ID: "100", Name: "Settings", Sequence: 5},
+ {ID: "101", Name: "General Settings", ParentID: "100", Sequence: 1, Action: "/web/settings"},
+ }
+ sections := render.BuildSidebarMenus(allMenus, "100", func(parser.MenuItem) bool { return true })
+ if len(sections) != 1 {
+ t.Fatalf("sections = %d; want 1", len(sections))
+ }
+ if sections[0].Name != "General Settings" || len(sections[0].SubMenus) != 1 {
+ t.Fatalf("unexpected section: %+v", sections[0])
+ }
+ if sections[0].SubMenus[0].Name != "General Settings" || sections[0].SubMenus[0].Action != "/web/settings" {
+ t.Fatalf("general settings link = %+v", sections[0].SubMenus[0])
+ }
+}
+
+func TestBuildSidebarMenus_excludesPersonalAccountSecurity(t *testing.T) {
+ allMenus := []parser.MenuItem{
+ {ID: "100", Name: "Settings", Sequence: 5},
+ {ID: "101", Name: "General Settings", ParentID: "100", Sequence: 1, Action: "/web/settings"},
+ {ID: "120", Name: "Personal", ParentID: "100", Sequence: 2},
+ {ID: "121", Name: "Account security", ParentID: "120", Sequence: 10, Action: "/web/settings/account"},
+ {ID: "110", Name: "Companies", ParentID: "100", Sequence: 10},
+ {ID: "111", Name: "All Companies", ParentID: "110", Sequence: 10, Action: "/web?action=1&menu_id=111"},
+ }
+ sections := render.BuildSidebarMenus(allMenus, "100", func(parser.MenuItem) bool { return true })
+ for _, sec := range sections {
+ if sec.Name == "Personal" {
+ t.Fatal("Personal section must not appear in settings sidebar")
+ }
+ for _, sm := range sec.SubMenus {
+ if sm.Name == "Account security" || sm.Action == "/web/settings/account" {
+ t.Fatalf("account security must not appear in sidebar: section %q link %q", sec.Name, sm.Name)
+ }
+ }
+ }
+}
+
func TestBuildSidebarMenus_skipsEmptySections(t *testing.T) {
allMenus := []parser.MenuItem{
{ID: "200", Name: "Contacts", Sequence: 20},
@@ -61,6 +150,9 @@ func TestBuildSidebarMenus_skipsEmptySections(t *testing.T) {
if sections[0].Name != "Contacts organization" {
t.Fatalf("section name = %q; want Contacts organization", sections[0].Name)
}
+ if len(sections[0].SubMenus) != 1 || sections[0].SubMenus[0].Name != "All Contacts" {
+ t.Fatalf("nested contact link: %+v", sections[0].SubMenus)
+ }
}
func TestSidebarHasMenus(t *testing.T) {
diff --git a/test/core/engine/render/render_extended_test.go b/test/core/engine/render/render_extended_test.go
index 76d960a7..71b8322b 100644
--- a/test/core/engine/render/render_extended_test.go
+++ b/test/core/engine/render/render_extended_test.go
@@ -17,6 +17,9 @@ func TestBuildSidebarMenusExtended(t *testing.T) {
if len(out) != 1 || out[0].Name != "Sales" {
t.Fatalf("sidebar: %+v", out)
}
+ if len(out[0].SubMenus) != 1 || out[0].SubMenus[0].Name != "Leads" {
+ t.Fatalf("flattened leads link: %+v", out[0].SubMenus)
+ }
}
func TestResolveActiveModuleID(t *testing.T) {
diff --git a/test/core/engine/render/settings_hub_categories_test.go b/test/core/engine/render/settings_hub_categories_test.go
new file mode 100644
index 00000000..bcf25b58
--- /dev/null
+++ b/test/core/engine/render/settings_hub_categories_test.go
@@ -0,0 +1,92 @@
+package render_test
+
+import (
+ "testing"
+
+ "sumeru/core/engine/parser"
+ "sumeru/core/engine/render"
+)
+
+func TestBuildSettingsHubCategories_securityAndDedupe(t *testing.T) {
+ allMenus := []parser.MenuItem{
+ {ID: "100", Name: "Settings", Sequence: 5},
+ {ID: "105", Name: "General Settings", ParentID: "100", Sequence: 1, Action: "/web?action=99&menu_id=105"},
+ {ID: "115", Name: "Security", ParentID: "100", Sequence: 15},
+ {ID: "116", Name: "Access Rights", ParentID: "115", Sequence: 10, Action: "/web?action=1&menu_id=116"},
+ {ID: "117", Name: "Field access matrix", ParentID: "115", Sequence: 40, Action: "/web?action=2&menu_id=117"},
+ {ID: "110", Name: "Companies", ParentID: "100", Sequence: 10},
+ {ID: "111", Name: "All Companies", ParentID: "110", Sequence: 10, Action: "/web?action=9&menu_id=111"},
+ {ID: "112", Name: "Duplicate Companies", ParentID: "110", Sequence: 20, Action: "/web?action=9&menu_id=112"},
+ }
+ menuAllowed := func(parser.MenuItem) bool { return true }
+ categories := render.BuildSettingsHubCategoriesForTest(allMenus, "100", menuAllowed, "105")
+ if len(categories) < 2 {
+ t.Fatalf("categories = %d; want at least 2", len(categories))
+ }
+ for _, c := range categories {
+ if c.Title == "General Settings" || c.Title == "Personal" {
+ t.Fatalf("hub nav menu must not appear as a category card: %q", c.Title)
+ }
+ }
+ var security *render.SettingsHubCategory
+ for i := range categories {
+ if categories[i].Title == "Security" {
+ security = &categories[i]
+ break
+ }
+ }
+ if security == nil {
+ t.Fatal("Security category missing")
+ }
+ linkNames := map[string]bool{}
+ for _, g := range security.Groups {
+ for _, l := range g.Links {
+ linkNames[l.Name] = true
+ }
+ }
+ if !linkNames["Field access matrix"] || !linkNames["Access Rights"] {
+ t.Fatalf("security links: %v", linkNames)
+ }
+ var companies *render.SettingsHubCategory
+ for i := range categories {
+ if categories[i].Title == "Companies" {
+ companies = &categories[i]
+ break
+ }
+ }
+ if companies == nil {
+ t.Fatal("Companies category missing")
+ }
+ companyLinks := 0
+ for _, g := range companies.Groups {
+ companyLinks += len(g.Links)
+ }
+ if companyLinks != 1 {
+ t.Fatalf("company links = %d; want 1 (deduped action)", companyLinks)
+ }
+}
+
+func TestBuildSettingsHubCategories_excludesPersonalAccountSecurity(t *testing.T) {
+ allMenus := []parser.MenuItem{
+ {ID: "100", Name: "Settings", Sequence: 5},
+ {ID: "105", Name: "General Settings", ParentID: "100", Sequence: 1, Action: "/web?action=99&menu_id=105"},
+ {ID: "120", Name: "Personal", ParentID: "100", Sequence: 2},
+ {ID: "121", Name: "Account security", ParentID: "120", Sequence: 10, Action: "/web/settings/account"},
+ {ID: "115", Name: "Security", ParentID: "100", Sequence: 15},
+ {ID: "116", Name: "Access Rights", ParentID: "115", Sequence: 10, Action: "/web?action=1&menu_id=116"},
+ }
+ menuAllowed := func(parser.MenuItem) bool { return true }
+ categories := render.BuildSettingsHubCategoriesForTest(allMenus, "100", menuAllowed, "105")
+ for _, c := range categories {
+ if c.Title == "Personal" {
+ t.Fatal("Personal must not appear on the settings hub")
+ }
+ for _, g := range c.Groups {
+ for _, l := range g.Links {
+ if l.Name == "Account security" || l.Href == "/web/settings/account" {
+ t.Fatalf("account security link must not appear on hub: category %q link %q", c.Title, l.Name)
+ }
+ }
+ }
+ }
+}
diff --git a/test/core/module/field_access_sync_test.go b/test/core/module/field_access_sync_test.go
new file mode 100644
index 00000000..a8757bd6
--- /dev/null
+++ b/test/core/module/field_access_sync_test.go
@@ -0,0 +1,25 @@
+package module_test
+
+import (
+ "strings"
+ "testing"
+
+ "sumeru/core/module"
+)
+
+func TestRegistryHasField_unknownModel(t *testing.T) {
+ if module.RegistryHasFieldForTest("not.a.model", "login") {
+ t.Fatal("expected unknown model")
+ }
+}
+
+func TestFieldAccessDefaultName(t *testing.T) {
+ name := module.FieldAccessDefaultNameForTest("core.user", "password", "base.group_system")
+ if !strings.Contains(name, "core.user") || !strings.Contains(name, "password") {
+ t.Fatalf("unexpected default name: %q", name)
+ }
+ global := module.FieldAccessDefaultNameForTest("core.user", "password", "")
+ if !strings.Contains(global, "global") {
+ t.Fatalf("expected global segment: %q", global)
+ }
+}
diff --git a/test/core/security/field_policy_labels_test.go b/test/core/security/field_policy_labels_test.go
new file mode 100644
index 00000000..8bc5ae1c
--- /dev/null
+++ b/test/core/security/field_policy_labels_test.go
@@ -0,0 +1,20 @@
+package security_test
+
+import (
+ "testing"
+
+ "sumeru/core/security"
+)
+
+func TestFieldKernelPolicyTags_password(t *testing.T) {
+ tags := security.FieldKernelPolicyTags("core.user", "password")
+ if len(tags) < 2 {
+ t.Fatalf("expected kernel tags for password; got %v", tags)
+ }
+}
+
+func TestFieldKernelPolicyTags_unknownField(t *testing.T) {
+ if tags := security.FieldKernelPolicyTags("core.user", "login"); len(tags) != 0 {
+ t.Fatalf("login should have no kernel tags; got %v", tags)
+ }
+}
diff --git a/test/core/server/web/helpers_coverage_test.go b/test/core/server/web/helpers_coverage_test.go
index 5d049dad..14df8f8d 100644
--- a/test/core/server/web/helpers_coverage_test.go
+++ b/test/core/server/web/helpers_coverage_test.go
@@ -8,7 +8,6 @@ import (
"testing"
"time"
- "sumeru/core/engine/render"
"sumeru/core/server/web"
)
@@ -177,11 +176,8 @@ func TestWebHelperExportsCoverage(t *testing.T) {
if got := web.ResolveExtraScripts([]string{"/a.js"}, []string{"/b.js"}); len(got) < 1 {
t.Fatalf("scripts: %v", got)
}
- section, ok := web.SettingsHubSectionFromSidebar(render.SidebarMenu{ID: "settings"})
- _ = section
- _ = ok
settingsPage := web.BuildSettingsHubPageData(context.Background(), "1")
- if settingsPage.Title == "" {
+ if settingsPage.Title == "" || settingsPage.SuppressSidebar || settingsPage.SuppressActivityDock {
t.Fatal("settings hub page")
}
if got := web.AppLogsViewStylesheets(); len(got) == 0 {
diff --git a/test/core/server/web/settings_acl_matrix_test.go b/test/core/server/web/settings_acl_matrix_test.go
new file mode 100644
index 00000000..e5c54977
--- /dev/null
+++ b/test/core/server/web/settings_acl_matrix_test.go
@@ -0,0 +1,45 @@
+package web_test
+
+import (
+ "testing"
+
+ "sumeru/core/server/web"
+)
+
+func TestFieldACLMatrixRowName(t *testing.T) {
+ global := web.FieldACLMatrixRowNameForTest("core.user", "password", 0)
+ if global != "matrix.core.user.password.global" {
+ t.Fatalf("global name = %q", global)
+ }
+ grouped := web.FieldACLMatrixRowNameForTest("core.user", "password", 3)
+ if grouped != "matrix.core.user.password.g3" {
+ t.Fatalf("group name = %q", grouped)
+ }
+}
+
+func TestModelACLMatrixRowName(t *testing.T) {
+ if got := web.ModelACLMatrixRowNameForTest("core.user", 0); got != "matrix.core.user.global" {
+ t.Fatalf("global = %q", got)
+ }
+ if got := web.ModelACLMatrixRowNameForTest("core.user", 5); got != "matrix.core.user.g5" {
+ t.Fatalf("group = %q", got)
+ }
+}
+
+func TestMatrixGroupsForDisplay_truncates(t *testing.T) {
+ displayLen, truncated := web.MatrixGroupDisplayLenForTest(50, false)
+ if !truncated {
+ t.Fatal("expected truncation")
+ }
+ if displayLen != 40 {
+ t.Fatalf("display len = %d; want 40 (includes Global column)", displayLen)
+ }
+}
+
+func TestFilterFieldNamesForTest(t *testing.T) {
+ fields := []string{"email", "login", "name", "password"}
+ got := web.FilterFieldNamesForTest(fields, "pass")
+ if len(got) != 1 || got[0] != "password" {
+ t.Fatalf("filter = %v", got)
+ }
+}
diff --git a/test/core/server/web/settings_hub_test.go b/test/core/server/web/settings_hub_test.go
index 66ec83f7..24090645 100644
--- a/test/core/server/web/settings_hub_test.go
+++ b/test/core/server/web/settings_hub_test.go
@@ -1,50 +1,26 @@
package web_test
import (
- "sumeru/core/server/web"
"testing"
- "sumeru/core/engine/parser"
- "sumeru/core/engine/render"
+ "sumeru/core/server/web"
)
-func TestSettingsHubSectionFromSidebar(t *testing.T) {
- section, ok := web.SettingsHubSectionFromSidebar(render.SidebarMenu{
- Name: "Companies",
- SubMenus: []parser.MenuItem{
- {Name: "All Companies", Action: "/web?menu_id=10"},
- {Name: "", Action: "/web?menu_id=11"},
- },
- })
- if !ok {
- t.Fatal("expected section with valid links")
- }
- if section.Title != "Companies" || len(section.Links) != 1 {
- t.Fatalf("unexpected section: %+v", section)
- }
- if section.FilterText != "companies all companies" {
- t.Fatalf("unexpected filter text: %q", section.FilterText)
- }
-}
-
-func TestSettingsHubSectionFromSidebarEmpty(t *testing.T) {
- _, ok := web.SettingsHubSectionFromSidebar(render.SidebarMenu{
- Name: "Users",
- SubMenus: []parser.MenuItem{
- {Name: "Users", Action: ""},
- },
- })
- if ok {
- t.Fatal("section without actionable links should be skipped")
- }
-}
-
func TestBuildSettingsHubPageData(t *testing.T) {
page := web.BuildSettingsHubPageData(t.Context(), "5")
if page.Title != web.TestSettingsHubPageTitle || !page.SettingsNavActive || page.ActiveMenuID != "5" {
t.Fatalf("unexpected page data: %+v", page)
}
+ if page.SuppressSidebar {
+ t.Fatal("settings hub should show the settings sidebar")
+ }
+ if page.SuppressActivityDock {
+ t.Fatal("settings hub should show the activity panel")
+ }
if len(page.ViewStylesheetURLs) != 1 || page.ViewStylesheetURLs[0] != web.TestSettingsHubStylesheetURL {
t.Fatalf("unexpected stylesheets: %v", page.ViewStylesheetURLs)
}
+ if len(page.ExtraScriptURLs) != 1 || page.ExtraScriptURLs[0] != web.TestSettingsHubScriptURL {
+ t.Fatalf("unexpected scripts: %v", page.ExtraScriptURLs)
+ }
}