From ea12629ce29d57ccfb1a5c9bc7b59be2061fe32b Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Tue, 29 Sep 2026 15:10:51 +0530 Subject: [PATCH 01/15] feat(SUM-PLAT-10): wire modifier view context from bootstrap Pass user and company ids into form, list, and header button modifier eval; validate required fields from resolveFieldModifiers on save. Document list column and empty action context limits in CONTRIBUTING. --- CONTRIBUTING.md | 2 +- core/swc/src/model/modifiers.ts | 12 ++++++++++++ core/swc/src/views/form/FormView.ts | 11 ++++++++--- core/swc/src/views/form/form-chrome.ts | 5 +++-- core/swc/src/views/list/ListView.ts | 6 ++++-- core/swc/tests/model/modifiers.test.ts | 10 ++++++++++ 6 files changed, 38 insertions(+), 8 deletions(-) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 02d63388..d58ce0dd 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -143,7 +143,7 @@ With developer mode on, the top-bar **bug menu** shows sectioned actions (Record ## View modifier expressions (SWC) -Dynamic `invisible` / `readonly` / `required` expressions in form and list arch are evaluated client-side with a **frozen allowlist** of identifiers: record field names, `user_id`, `company_id`, and `context` (object). Expressions must be boolean JavaScript fragments (for example `state == 'done'`), not statements. Tokens such as `function`, `=>`, `[`, `` ` ``, or `;` are rejected. Static arch flags still apply when an expression is missing or invalid. +Dynamic `invisible` / `readonly` / `required` expressions in form and list arch are evaluated client-side with a **frozen allowlist** of identifiers: record field names, `user_id`, `company_id`, and `context` (object). Expressions must be boolean JavaScript fragments (for example `state == 'done'`), not statements. Tokens such as `function`, `=>`, `[`, `` ` ``, or `;` are rejected. Static arch flags still apply when an expression is missing or invalid. List column expressions that reference record fields are evaluated without a row context (static arch flags apply). Action `context` on the workspace payload is not wired yet — `context` is an empty object until then. ## Pull requests 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/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); + }); }); From 0e053aa3c2bfb5d8b89287d1cc43625a5747f0bc Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Tue, 29 Sep 2026 15:11:01 +0530 Subject: [PATCH 02/15] feat(SUM-PLAT-11): Sum t-call, t-set, t-att, and sub-template scope Add document parsing with t-name templates, safe expressions, merged static and dynamic class attrs, t-set over remaining siblings, and scope destructuring in named templates. Fix self-closing tags and add composition runtime tests. --- core/swc/src/template/helpers.ts | 8 + core/swc/src/template/sum/codegen.ts | 335 +++++++++++++++--- core/swc/src/template/sum/expr.ts | 42 +++ core/swc/src/template/sum/parser.ts | 46 ++- .../template/fixtures/composition.sum.xml | 5 + core/swc/tests/template/sum-compiler.test.ts | 75 ++++ 6 files changed, 459 insertions(+), 52 deletions(-) create mode 100644 core/swc/src/template/sum/expr.ts create mode 100644 core/swc/tests/template/fixtures/composition.sum.xml 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)}\``; + return `html\`<${el.tag} ${attrToHtml(attrs)}>${codegenChildren(el.children, scopeVar, templates)}\``; } -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}\`)}`; } 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}`; } +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/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/); + }); }); From 6c8e9eefd016d92d88b51d942ef2ed83b5bc0f4c Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Tue, 29 Sep 2026 15:11:14 +0530 Subject: [PATCH 03/15] feat(SUM-PLAT-12): sync sys.field.access CSV and orphan warnings Load security/sys.field.access.csv on module sync, validate model and field names, and warn on orphan rows after sync. Document CSV format and list export in MODULE_STANDARD. --- .../module/addon_template/MODULE_STANDARD.txt | 7 + core/module/data_sync.go | 6 +- core/module/field_access_sync.go | 153 ++++++++++++++++++ core/module/testexports.go | 10 ++ test/core/module/field_access_sync_test.go | 25 +++ 5 files changed, 200 insertions(+), 1 deletion(-) create mode 100644 core/module/field_access_sync.go create mode 100644 test/core/module/field_access_sync_test.go diff --git a/core/module/addon_template/MODULE_STANDARD.txt b/core/module/addon_template/MODULE_STANDARD.txt index 406d5e40..7a258f5c 100644 --- a/core/module/addon_template/MODULE_STANDARD.txt +++ b/core/module/addon_template/MODULE_STANDARD.txt @@ -110,6 +110,13 @@ 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 existing rows from Settings → Field Access (list view CSV export with a model filter). + 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/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) + } +} From d294ae935b4d720046c256888979f6a3f2c02945 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Tue, 29 Sep 2026 15:11:20 +0530 Subject: [PATCH 04/15] feat(SUM-PLAT-12): field access matrix under Settings Add GET/POST /web/settings/field-acl for system admins, batch upsert of sys.field.access deny rules, menu and URL action, flash messages, and hub CSS. --- addons/base/views/i18n_actions.xml | 1 + addons/base/views/menus.xml | 1 + .../engine/assets/css/sumeru-settings-hub.css | 31 ++ .../templates/settings_field_acl_inner.html | 58 +++ core/server/web/query_flash.go | 4 + core/server/web/routes_table.go | 1 + core/server/web/settings_field_acl.go | 364 ++++++++++++++++++ core/server/web/web_constants.go | 1 + 8 files changed, 461 insertions(+) create mode 100644 core/engine/templates/settings_field_acl_inner.html create mode 100644 core/server/web/settings_field_acl.go diff --git a/addons/base/views/i18n_actions.xml b/addons/base/views/i18n_actions.xml index c01d1bfb..53cfe05b 100644 --- a/addons/base/views/i18n_actions.xml +++ b/addons/base/views/i18n_actions.xml @@ -4,5 +4,6 @@ + diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index 4f3be594..fb96d7a5 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -28,6 +28,7 @@ + diff --git a/core/engine/assets/css/sumeru-settings-hub.css b/core/engine/assets/css/sumeru-settings-hub.css index 32353eba..dcee1d6b 100644 --- a/core/engine/assets/css/sumeru-settings-hub.css +++ b/core/engine/assets/css/sumeru-settings-hub.css @@ -252,3 +252,34 @@ text-overflow: ellipsis; white-space: nowrap; } + +.sum-settings-field-acl-picker { + display: flex; + flex-wrap: wrap; + align-items: end; + gap: 0.75rem; + margin-bottom: 1.5rem; +} + +.sum-settings-field-acl-scroll { + overflow-x: auto; + margin: 1rem 0; +} + +.sum-settings-field-acl-table { + border-collapse: collapse; + min-width: 100%; + font-size: 0.875rem; +} + +.sum-settings-field-acl-table th, +.sum-settings-field-acl-table td { + border: 1px solid var(--sum-border, #ddd); + padding: 0.35rem 0.5rem; + text-align: center; +} + +.sum-settings-field-acl-table th[scope="row"] { + text-align: left; + white-space: nowrap; +} diff --git a/core/engine/templates/settings_field_acl_inner.html b/core/engine/templates/settings_field_acl_inner.html new file mode 100644 index 00000000..4b42db61 --- /dev/null +++ b/core/engine/templates/settings_field_acl_inner.html @@ -0,0 +1,58 @@ +
+
+
+

Field access matrix

+

Deny read or write per field and security group. Empty cells mean no extra sys.field.access rule.

+
+ Back to Settings +
+ +
+ + + +
+ + {{if .Model}} + {{if .Rows}} +
+ + +

Model: {{.Model}}. Export rows from Field Access list (CSV) to copy rules across environments.

+
+ + + + + {{range (index .Rows 0).Cells}} + + {{end}} + + + + {{range (index .Rows 0).Cells}} + + + {{end}} + + + + {{range .Rows}} + + + {{range .Cells}} + + + {{end}} + + {{end}} + +
Field{{.GroupLabel}}
Deny readDeny write
{{.FieldName}}{{if .HasGroups}} (groups attr){{end}}
+
+ +
+ {{else}} +

No fields found for this model.

+ {{end}} + {{end}} +
diff --git a/core/server/web/query_flash.go b/core/server/web/query_flash.go index ebd79ddf..8a758e52 100644 --- a/core/server/web/query_flash.go +++ b/core/server/web/query_flash.go @@ -51,6 +51,10 @@ 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 "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..726cdde0 100644 --- a/core/server/web/routes_table.go +++ b/core/server/web/routes_table.go @@ -96,6 +96,7 @@ func registerActionRoutes() { func registerSettingsRoutes() { registerSession(http.MethodGet, settingsRoute, SettingsHubHandler) registerSettingsAccountRoutes() + registerSettingsFieldACLRoutes() registerSession(http.MethodGet, appLogsRoute, AppLogsHandler) registerSession(http.MethodGet, metricsRoute, MetricsHandler) } diff --git a/core/server/web/settings_field_acl.go b/core/server/web/settings_field_acl.go new file mode 100644 index 00000000..fc99cce1 --- /dev/null +++ b/core/server/web/settings_field_acl.go @@ -0,0 +1,364 @@ +package web + +import ( + "context" + "fmt" + "net/http" + "sort" + "strconv" + "strings" + + "sumeru/core/engine/render" + "sumeru/core/orm" +) + +const settingsFieldACLRoute = "/web/settings/field-acl" + +type fieldACLMatrixCell struct { + FieldName string + GroupID int + GroupLabel string + DenyRead bool + DenyWrite bool + HasGroups bool +} + +type fieldACLMatrixRow struct { + FieldName string + HasGroups bool + Cells []fieldACLMatrixCell +} + +type settingsFieldACLData struct { + CSRFToken string + Model string + Groups []fieldACLGroupCol + Rows []fieldACLMatrixRow + Flash render.FlashMessage +} + +type fieldACLGroupCol struct { + ID int + Label string +} + +func registerSettingsFieldACLRoutes() { + registerSession(http.MethodGet, settingsFieldACLRoute, SettingsFieldACLGetHandler) + registerSession(http.MethodPost, settingsFieldACLRoute, SettingsFieldACLPostHandler) +} + +func SettingsFieldACLGetHandler(w http.ResponseWriter, r *http.Request) { + if !requireLogin(w, r) { + return + } + if !requireSystemAdmin(w, r, true) { + return + } + ctx := r.Context() + menuIDStr, ok := resolveSettingsRootMenuID(w, r, ctx) + if !ok { + return + } + model := strings.TrimSpace(r.URL.Query().Get("model")) + flash, _ := flashFromQueryMessage(r.URL.Query().Get("msg")) + page, err := buildFieldACLMatrixPage(ctx, model) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + page.CSRFToken = CSRFTokenForRequest(r) + page.Flash = flash + renderSettingsFieldACLPage(w, r, page, menuIDStr) +} + +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 { + return applyFieldACLMatrixPost(elevated, model, r.PostForm) + }) + if err != nil { + redirectWithWebMessage(w, r, settingsFieldACLRoute+"?model="+model, "field_acl_failed") + return + } + redirectWithWebMessage(w, r, settingsFieldACLRoute+"?model="+model, "field_acl_saved") +} + +func applyFieldACLMatrixPost(ctx context.Context, model string, form map[string][]string) error { + groups, err := loadFieldACLGroups(ctx) + if err != nil { + return err + } + groupIDs := []int{0} + for _, g := range groups { + 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 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 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 string) (settingsFieldACLData, error) { + out := settingsFieldACLData{Model: model} + if model == "" { + return out, nil + } + if orm.RegistryModel(model) == nil { + return out, fmt.Errorf("unknown model %q", model) + } + groups, err := loadFieldACLGroups(ctx) + if err != nil { + return out, err + } + out.Groups = groups + existing, err := loadFieldACLExistingByCell(ctx, model) + if err != nil { + return out, err + } + for _, field := range listFieldACLFields(model) { + row := fieldACLMatrixRow{FieldName: field} + for _, g := range append([]fieldACLGroupCol{{ID: 0, Label: "Global"}}, groups...) { + 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 + } + if fieldHasGroupsAttr(model, field) { + cell.HasGroups = true + row.HasGroups = true + } + 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 loadFieldACLGroups(ctx context.Context) ([]fieldACLGroupCol, error) { + rows, err := orm.Search(ctx, "core.group", nil) + if err != nil { + return nil, err + } + out := make([]fieldACLGroupCol, 0, len(rows)) + for _, row := range rows { + id := intField(row["id"]) + if id <= 0 { + continue + } + out = append(out, fieldACLGroupCol{ID: id, Label: stringField(row["name"])}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Label < out[j].Label }) + return out, 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 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 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, + SuppressActivityDock: true, + 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/web_constants.go b/core/server/web/web_constants.go index eae8544e..bb1c41b5 100644 --- a/core/server/web/web_constants.go +++ b/core/server/web/web_constants.go @@ -146,6 +146,7 @@ const ( settingsHubStylesheetURL = "/static/css/sumeru-settings-hub.css" settingsHubBodyClass = " sum-body--settings-hub" settingsAccountInnerTemplate = "settings_account_inner.html" + settingsFieldACLInnerTemplate = "settings_field_acl_inner.html" groupUserXML = "base.group_user" ) From cbc9048a374cdb5352ada13813c3bf7eed617289 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Wed, 30 Sep 2026 01:09:42 +0530 Subject: [PATCH 05/15] fix(base): wire General Settings menu to settings hub URL action Point menu_general_settings at action_settings.hub (/web/settings) and stop syncing the legacy window action; keep a minimal res.config.settings model/view for CRM inherit only. --- addons/base/manifest.json | 1 - addons/base/models/res_config_settings.go | 6 ++-- addons/base/views/menus.xml | 35 ++++++++++--------- addons/base/views/platform_actions.xml | 3 ++ .../views/res_config_settings_actions.xml | 6 ---- .../views/res_config_settings_form_views.xml | 4 ++- 6 files changed, 26 insertions(+), 29 deletions(-) delete mode 100644 addons/base/views/res_config_settings_actions.xml diff --git a/addons/base/manifest.json b/addons/base/manifest.json index 998880ec..70e95540 100644 --- a/addons/base/manifest.json +++ b/addons/base/manifest.json @@ -12,7 +12,6 @@ "views/core_company_actions.xml", "views/core_users_actions.xml", "views/platform_actions.xml", - "views/res_config_settings_actions.xml", "views/res_config_settings_form_views.xml", "views/geo_actions.xml", "views/i18n_actions.xml", diff --git a/addons/base/models/res_config_settings.go b/addons/base/models/res_config_settings.go index 0d2ed938..68c14a37 100644 --- a/addons/base/models/res_config_settings.go +++ b/addons/base/models/res_config_settings.go @@ -1,10 +1,8 @@ package models -import ( - "sumeru/core/sdk" -) +import "sumeru/core/sdk" -// ResConfigSettings is the platform settings shell extended by application modules. +// ResConfigSettings is a transient settings shell model extended by addons (not linked from base menus). type ResConfigSettings struct { sdk.Model `sumeru:"model=res.config.settings"` } diff --git a/addons/base/views/menus.xml b/addons/base/views/menus.xml index fb96d7a5..a65a26d0 100644 --- a/addons/base/views/menus.xml +++ b/addons/base/views/menus.xml @@ -5,31 +5,36 @@ - + - + - - + + + + + + + + + + + + + + - - - - - - - @@ -41,16 +46,12 @@ - + - - - - - + diff --git a/addons/base/views/platform_actions.xml b/addons/base/views/platform_actions.xml index 5c80a781..9be4584b 100644 --- a/addons/base/views/platform_actions.xml +++ b/addons/base/views/platform_actions.xml @@ -5,5 +5,8 @@ + + + diff --git a/addons/base/views/res_config_settings_actions.xml b/addons/base/views/res_config_settings_actions.xml deleted file mode 100644 index bb885d6c..00000000 --- a/addons/base/views/res_config_settings_actions.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - diff --git a/addons/base/views/res_config_settings_form_views.xml b/addons/base/views/res_config_settings_form_views.xml index b822d028..b4eafcf7 100644 --- a/addons/base/views/res_config_settings_form_views.xml +++ b/addons/base/views/res_config_settings_form_views.xml @@ -2,7 +2,9 @@ - +
+ +
From f8fdc565876c5ed06f3a5546b811110c8bde0057 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Wed, 30 Sep 2026 01:09:49 +0530 Subject: [PATCH 06/15] feat(render): build settings hub from menu categories Add category cards with dedupe and hub template/CSS; exclude hub nav from center cards. --- .../engine/assets/css/sumeru-settings-hub.css | 71 +++++- core/engine/render/settings_hub_menus.go | 216 ++++++++++++++++++ core/engine/templates/settings_hub_inner.html | 45 ++-- .../render/settings_hub_categories_test.go | 92 ++++++++ 4 files changed, 397 insertions(+), 27 deletions(-) create mode 100644 core/engine/render/settings_hub_menus.go create mode 100644 test/core/engine/render/settings_hub_categories_test.go diff --git a/core/engine/assets/css/sumeru-settings-hub.css b/core/engine/assets/css/sumeru-settings-hub.css index dcee1d6b..d9fbf0b0 100644 --- a/core/engine/assets/css/sumeru-settings-hub.css +++ b/core/engine/assets/css/sumeru-settings-hub.css @@ -9,6 +9,16 @@ padding: 0 0.25rem 2.5rem; } +.sum-settings-account-lead { + margin: 0 0 1rem; + font-size: var(--sum-font-size-sm); + color: var(--sum-muted); +} + +.sum-settings-account-block { + margin-bottom: 1.25rem; +} + .sum-settings-hub-head { display: flex; flex-wrap: wrap; @@ -135,19 +145,72 @@ gap: 0.35rem; } -.sum-settings-hub-links a { - display: inline-block; - padding: 0.25rem 0; +.sum-settings-hub-group-title { + margin: 0.75rem 0 0.35rem; + font-size: var(--sum-font-size-sm); + font-weight: var(--sum-font-weight-semibold); + color: var(--sum-ink); +} + +.sum-settings-hub-group-title:first-of-type { + margin-top: 0; +} + +.sum-settings-hub-link { + display: flex; + align-items: center; + gap: 0.45rem; + padding: 0.35rem 0; font-size: var(--sum-font-size-md); font-weight: var(--sum-font-weight-medium); color: var(--sum-header); text-decoration: none !important; } -.sum-settings-hub-links a:hover { +.sum-settings-hub-link:hover { text-decoration: underline !important; } +.sum-settings-hub-link-icon { + width: 1.1rem; + flex-shrink: 0; +} + +.sum-settings-hub-links li.is-filtered-out { + display: none; +} + +.sum-settings-hub-single-link { + display: inline-flex; + align-items: center; + margin-top: 0.15rem; + padding: 0.45rem 0; + font-size: var(--sum-font-size-md); + font-weight: var(--sum-font-weight-semibold); + color: var(--sum-header); + text-decoration: none !important; +} + +.sum-settings-hub-single-link:hover { + text-decoration: underline !important; +} + +.sum-settings-hub-footer { + margin: 2rem 0 0; + padding-top: 1rem; + border-top: 1px solid var(--sum-line); + font-size: var(--sum-font-size-sm); +} + +.sum-settings-hub-footer-link { + color: var(--sum-header); + font-weight: var(--sum-font-weight-medium); +} + +.sum-body--settings-hub .sum-shell--no-sidebar .sum-content { + max-width: 960px; +} + .sum-settings-hub-empty { grid-column: 1 / -1; margin: 0; diff --git a/core/engine/render/settings_hub_menus.go b/core/engine/render/settings_hub_menus.go new file mode 100644 index 00000000..80546d97 --- /dev/null +++ b/core/engine/render/settings_hub_menus.go @@ -0,0 +1,216 @@ +package render + +import ( + "context" + "strings" + + "sumeru/core/engine/parser" + "sumeru/core/orm" +) + +// SettingsHubLink is one navigable entry on the settings hub. +type SettingsHubLink struct { + Name string + Href string + MenuID string + WebIcon string + ActionKey string +} + +// SettingsHubGroup is an optional subgroup inside a category card. +type SettingsHubGroup struct { + Title string + Links []SettingsHubLink +} + +// SettingsHubCategory is a top-level settings hub section (settings root child). +type SettingsHubCategory struct { + Title string + Sequence int + FilterText string + SingleLink bool + Groups []SettingsHubGroup +} + +func hubLinkActionKey(mi parser.MenuItem) string { + action := strings.TrimSpace(mi.Action) + if idx := strings.Index(action, "action="); idx >= 0 { + rest := action[idx+len("action="):] + if amp := strings.Index(rest, "&"); amp >= 0 { + rest = rest[:amp] + } + rest = strings.TrimSpace(rest) + if rest != "" { + return "action:" + rest + } + } + return "menu:" + strings.TrimSpace(mi.ID) +} + +func hubLinkFromMenu(mi parser.MenuItem, seen map[string]struct{}) (SettingsHubLink, bool) { + name := strings.TrimSpace(mi.Name) + href := strings.TrimSpace(mi.Action) + if name == "" || href == "" || settingsNavExcludedLink(name, href) { + return SettingsHubLink{}, false + } + key := hubLinkActionKey(mi) + if _, dup := seen[key]; dup { + return SettingsHubLink{}, false + } + seen[key] = struct{}{} + return SettingsHubLink{ + Name: name, + Href: href, + MenuID: mi.ID, + WebIcon: mi.WebIcon, + ActionKey: key, + }, true +} + +func settingsHubFilterText(categoryTitle string, groups []SettingsHubGroup) string { + terms := []string{strings.ToLower(strings.TrimSpace(categoryTitle))} + for _, g := range groups { + if t := strings.TrimSpace(g.Title); t != "" { + terms = append(terms, strings.ToLower(t)) + } + for _, l := range g.Links { + if n := strings.TrimSpace(l.Name); n != "" { + terms = append(terms, strings.ToLower(n)) + } + } + } + return strings.Join(terms, " ") +} + +func buildSettingsHubCategory(catNode parser.MenuItem, allMenus []parser.MenuItem, menuAllowed func(parser.MenuItem) bool, seen map[string]struct{}) SettingsHubCategory { + var groups []SettingsHubGroup + var defaultLinks []SettingsHubLink + + if menuItemHasWindowOrURLAction(catNode) { + if link, ok := hubLinkFromMenu(catNode, seen); ok { + defaultLinks = append(defaultLinks, link) + } + } + + var children []parser.MenuItem + for _, sub := range allMenus { + if sub.ParentID == catNode.ID && menuAllowed(sub) { + children = append(children, sub) + } + } + sortMenuItemsBySequenceName(children) + + for _, child := range children { + if settingsNavExcludedSection(child.Name) { + continue + } + hasChildren := menuItemHasAllowedChild(child.ID, allMenus, menuAllowed) + isContainer := hasChildren && !menuItemHasWindowOrURLAction(child) + if isContainer { + subMenus := collectSidebarLinks(child.ID, allMenus, menuAllowed) + var links []SettingsHubLink + for _, mi := range subMenus { + if link, ok := hubLinkFromMenu(mi, seen); ok { + links = append(links, link) + } + } + if len(links) > 0 { + groups = append(groups, SettingsHubGroup{Title: child.Name, Links: links}) + } + continue + } + if menuItemHasWindowOrURLAction(child) || !hasChildren { + if link, ok := hubLinkFromMenu(child, seen); ok { + defaultLinks = append(defaultLinks, link) + } + } + } + + if len(defaultLinks) > 0 { + groups = append([]SettingsHubGroup{{Links: defaultLinks}}, groups...) + } + + singleLink := len(groups) == 1 && len(groups[0].Links) == 1 && groups[0].Title == "" && + strings.EqualFold(strings.TrimSpace(groups[0].Links[0].Name), strings.TrimSpace(catNode.Name)) + + return SettingsHubCategory{ + Title: catNode.Name, + Sequence: catNode.Sequence, + Groups: groups, + SingleLink: singleLink, + FilterText: settingsHubFilterText(catNode.Name, groups), + } +} + +// BuildSettingsHubCategories lists settings hub cards from the menu tree under settingsRootMenuID. +func BuildSettingsHubCategories(ctx context.Context, settingsRootMenuID string) []SettingsHubCategory { + settingsRootMenuID = strings.TrimSpace(settingsRootMenuID) + if settingsRootMenuID == "" { + return nil + } + allMenus := fetchShellMenus(ctx) + if len(allMenus) == 0 { + return nil + } + uid := orm.UIDFromContext(ctx) + menuAllowed := func(mi parser.MenuItem) bool { return shellMenuAllowed(ctx, uid, mi) } + + var rootChildren []parser.MenuItem + for _, m := range allMenus { + if m.ParentID == settingsRootMenuID && menuAllowed(m) { + rootChildren = append(rootChildren, m) + } + } + sortMenuItemsBySequenceName(rootChildren) + + seen := make(map[string]struct{}) + var categories []SettingsHubCategory + for _, catNode := range rootChildren { + if isSettingsHubNavMenu(ctx, catNode.ID) || settingsNavExcludedSection(catNode.Name) { + continue + } + cat := buildSettingsHubCategory(catNode, allMenus, menuAllowed, seen) + if len(cat.Groups) == 0 { + continue + } + categories = append(categories, cat) + } + return categories +} + +// BuildSettingsHubCategoriesForTest builds hub categories from an in-memory menu tree (tests). +func BuildSettingsHubCategoriesForTest(allMenus []parser.MenuItem, settingsRootMenuID string, menuAllowed func(parser.MenuItem) bool, skipMenuIDs ...string) []SettingsHubCategory { + settingsRootMenuID = strings.TrimSpace(settingsRootMenuID) + if settingsRootMenuID == "" || menuAllowed == nil { + return nil + } + skip := map[string]struct{}{} + for _, id := range skipMenuIDs { + if id = strings.TrimSpace(id); id != "" { + skip[id] = struct{}{} + } + } + var rootChildren []parser.MenuItem + for _, m := range allMenus { + if m.ParentID == settingsRootMenuID && menuAllowed(m) { + rootChildren = append(rootChildren, m) + } + } + sortMenuItemsBySequenceName(rootChildren) + seen := make(map[string]struct{}) + var categories []SettingsHubCategory + for _, catNode := range rootChildren { + if _, omit := skip[catNode.ID]; omit { + continue + } + if settingsNavExcludedSection(catNode.Name) { + continue + } + cat := buildSettingsHubCategory(catNode, allMenus, menuAllowed, seen) + if len(cat.Groups) == 0 { + continue + } + categories = append(categories, cat) + } + return categories +} diff --git a/core/engine/templates/settings_hub_inner.html b/core/engine/templates/settings_hub_inner.html index 42ec813a..61e9522e 100644 --- a/core/engine/templates/settings_hub_inner.html +++ b/core/engine/templates/settings_hub_inner.html @@ -2,51 +2,50 @@

Settings

-

Configure your organization, users, and installed modules from one place.

+

Configure your organization, security, and platform options by category.

- {{if .CompaniesMenuHref}} - Manage companies - {{end}}
- +
- {{range .Sections}} + {{range .Categories}}

{{.Title}}

+ {{if .SingleLink}} + {{range $gi, $g := .Groups}}{{range $g.Links}} + {{.Name}} + {{end}}{{end}} + {{else}} + {{range .Groups}} + {{if .Title}}

{{.Title}}

{{end}} + {{end}} + {{end}}
{{else}}

No configuration sections are available for your account.

{{end}}
- {{if .AppTiles}} -

Installed applications

-

Open an app’s workspace to change its configuration menus.

-
- {{range .AppTiles}} - - {{end}} -
+ {{if .AppsListHref}} + {{end}}
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) + } + } + } + } +} From f69567cf85367490035c4844f9617cc30f8703d3 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Wed, 30 Sep 2026 01:09:57 +0530 Subject: [PATCH 07/15] fix(render): sidebar General Settings href and settings nav helpers Consolidate settings nav rules, map URL actions to direct paths, force /web/settings for menu_general_settings, and hide Personal/Account security from the settings sidebar. --- core/engine/render/menus.go | 122 ++++++++++++++++-- core/engine/render/settings_nav.go | 81 ++++++++++++ .../core/engine/render/menus_settings_test.go | 92 +++++++++++++ .../engine/render/render_extended_test.go | 3 + 4 files changed, 285 insertions(+), 13 deletions(-) create mode 100644 core/engine/render/settings_nav.go diff --git a/core/engine/render/menus.go b/core/engine/render/menus.go index 0c5cdbfe..0a5c56b1 100644 --- a/core/engine/render/menus.go +++ b/core/engine/render/menus.go @@ -84,6 +84,8 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { } } + urlActionHrefs := loadURLActionHrefs(ctx) + var allMenus []parser.MenuItem for rows.Next() { var id int @@ -107,7 +109,11 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { m.ParentID = fmt.Sprintf("%d", parentID.Int64) } if actionID.Valid && actionID.Int64 != 0 { - m.Action = fmt.Sprintf("/web?action=%d&menu_id=%d", actionID.Int64, id) + 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" } @@ -116,9 +122,31 @@ func fetchShellMenus(ctx context.Context) []parser.MenuItem { if err := rows.Err(); err != nil { applog.WarnMsg(ctx, "render", "menus", "Menu rows error", err, nil) } + applySettingsNavHrefOverrides(ctx, allMenus) return allMenus } +func loadURLActionHrefs(ctx context.Context) map[int]string { + out := make(map[int]string) + rows, err := orm.Search(ctx, "sys.action.url", nil) + if err != nil { + applog.WarnMsg(ctx, "render", "menus", "Error loading URL actions", err, nil) + return out + } + for _, row := range rows { + id, ok := orm.CoerceInt64(row["id"]) + if !ok || id <= 0 { + continue + } + url := strings.TrimSpace(orm.AsString(row["url"])) + if url == "" || !strings.HasPrefix(url, "/") { + continue + } + out[int(id)] = url + } + return out +} + func buildTopBarMenus(allMenus []parser.MenuItem, appMods map[string]struct{}, menuAllowed func(parser.MenuItem) bool) []parser.MenuItem { var topMenus []parser.MenuItem for _, m := range allMenus { @@ -188,6 +216,80 @@ func shellTitleForModule(allMenus []parser.MenuItem, activeModuleID, fallback st return fallback } +func menuItemHasWindowOrURLAction(mi parser.MenuItem) bool { + action := strings.TrimSpace(mi.Action) + if strings.Contains(action, "action=") { + return true + } + return strings.HasPrefix(action, "/web/") && !strings.HasPrefix(action, "/web?") +} + +func filterSidebarLinks(links []parser.MenuItem) []parser.MenuItem { + if len(links) == 0 { + return links + } + out := links[:0] + for _, mi := range links { + if settingsNavExcludedSection(mi.Name) || settingsNavExcludedLink(mi.Name, mi.Action) { + continue + } + out = append(out, mi) + } + return out +} + +func menuItemHasAllowedChild(parentID string, allMenus []parser.MenuItem, menuAllowed func(parser.MenuItem) bool) bool { + for _, sub := range allMenus { + if sub.ParentID == parentID && menuAllowed(sub) { + return true + } + } + return false +} + +// collectSidebarLinks returns depth-first navigable descendants of parentID (not parent itself). +func collectSidebarLinks(parentID string, allMenus []parser.MenuItem, menuAllowed func(parser.MenuItem) bool) []parser.MenuItem { + var children []parser.MenuItem + for _, sub := range allMenus { + if sub.ParentID == parentID && menuAllowed(sub) { + children = append(children, sub) + } + } + sort.Slice(children, func(i, j int) bool { + if children[i].Sequence != children[j].Sequence { + return children[i].Sequence < children[j].Sequence + } + return children[i].Name < children[j].Name + }) + + var out []parser.MenuItem + for _, child := range children { + if settingsNavExcludedSection(child.Name) || settingsNavExcludedLink(child.Name, child.Action) { + continue + } + hasChildren := menuItemHasAllowedChild(child.ID, allMenus, menuAllowed) + switch { + case menuItemHasWindowOrURLAction(child): + out = append(out, child) + case !hasChildren: + out = append(out, child) + } + if hasChildren { + out = append(out, collectSidebarLinks(child.ID, allMenus, menuAllowed)...) + } + } + return out +} + +func sortMenuItemsBySequenceName(items []parser.MenuItem) { + sort.Slice(items, func(i, j int) bool { + if items[i].Sequence != items[j].Sequence { + return items[i].Sequence < items[j].Sequence + } + return items[i].Name < items[j].Name + }) +} + func buildSidebarMenus(allMenus []parser.MenuItem, activeModuleID string, menuAllowed func(parser.MenuItem) bool) []SidebarMenu { if activeModuleID == "" { return nil @@ -195,22 +297,16 @@ func buildSidebarMenus(allMenus []parser.MenuItem, activeModuleID string, menuAl var sidebarMenus []SidebarMenu var sections []SidebarMenu for _, m := range allMenus { - if m.ParentID != activeModuleID || !menuAllowed(m) { + if m.ParentID != activeModuleID || !menuAllowed(m) || settingsNavExcludedSection(m.Name) { continue } section := SidebarMenu{ID: m.ID, Name: m.Name, Sequence: m.Sequence} - for _, sub := range allMenus { - if sub.ParentID != m.ID || !menuAllowed(sub) { - continue - } - section.SubMenus = append(section.SubMenus, sub) + links := collectSidebarLinks(m.ID, allMenus, menuAllowed) + if menuItemHasWindowOrURLAction(m) && !settingsNavExcludedLink(m.Name, m.Action) { + links = append([]parser.MenuItem{m}, links...) } - sort.Slice(section.SubMenus, func(i, j int) bool { - if section.SubMenus[i].Sequence != section.SubMenus[j].Sequence { - return section.SubMenus[i].Sequence < section.SubMenus[j].Sequence - } - return section.SubMenus[i].Name < section.SubMenus[j].Name - }) + sortMenuItemsBySequenceName(links) + section.SubMenus = filterSidebarLinks(links) if len(section.SubMenus) == 0 { continue } diff --git a/core/engine/render/settings_nav.go b/core/engine/render/settings_nav.go new file mode 100644 index 00000000..4f0cdebb --- /dev/null +++ b/core/engine/render/settings_nav.go @@ -0,0 +1,81 @@ +package render + +import ( + "context" + "strconv" + "strings" + + "sumeru/core/engine/parser" + "sumeru/core/orm" +) + +// MenuGeneralSettingsXMLID is the settings hub sidebar entry (URL action → SettingsHubPath). +const MenuGeneralSettingsXMLID = "base.menu_general_settings" + +const ( + SettingsHubPath = "/web/settings" + SettingsAccountPath = "/web/settings/account" +) + +func settingsNavExcludedSection(title string) bool { + return strings.EqualFold(strings.TrimSpace(title), "Personal") +} + +func settingsNavExcludedLink(name, href string) bool { + if strings.EqualFold(strings.TrimSpace(name), "Account security") { + return true + } + return strings.Contains(strings.TrimSpace(href), SettingsAccountPath) +} + +func menuIDForXMLID(ctx context.Context, xmlID string) string { + xmlID = strings.TrimSpace(xmlID) + if xmlID == "" { + return "" + } + id, _, err := orm.ResolveXmlId(ctx, xmlID) + if err != nil || id <= 0 { + return "" + } + return strconv.Itoa(id) +} + +func isSettingsHubNavMenu(ctx context.Context, menuID string) bool { + menuID = strings.TrimSpace(menuID) + if menuID == "" { + return false + } + return menuID == menuIDForXMLID(ctx, MenuGeneralSettingsXMLID) +} + +// MenuIDIsGeneralSettings reports whether menuID is base.menu_general_settings. +func MenuIDIsGeneralSettings(ctx context.Context, menuID string) bool { + return isSettingsHubNavMenu(ctx, menuID) +} + +func applySettingsNavHrefOverrides(ctx context.Context, menus []parser.MenuItem) { + generalID := menuIDForXMLID(ctx, MenuGeneralSettingsXMLID) + if generalID == "" { + return + } + for i := range menus { + if menus[i].ID == generalID { + menus[i].Action = SettingsHubPath + return + } + } +} + +// ApplySettingsNavHrefOverridesForTest applies in-memory href overrides (tests without DB xml ids). +func ApplySettingsNavHrefOverridesForTest(menus []parser.MenuItem, generalSettingsMenuID string) { + generalSettingsMenuID = strings.TrimSpace(generalSettingsMenuID) + if generalSettingsMenuID == "" { + return + } + for i := range menus { + if menus[i].ID == generalSettingsMenuID { + menus[i].Action = SettingsHubPath + return + } + } +} 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) { From bf62e7d34f8b3e598865eb72e8a9c6fec35da9d4 Mon Sep 17 00:00:00 2001 From: AIRONAX Developer Date: Wed, 30 Sep 2026 01:10:03 +0530 Subject: [PATCH 08/15] refactor(web): settings hub overview and dedicated account security route Remove password flow from the hub; restore /web/settings/account; align shell page data and tests. --- .../templates/settings_account_inner.html | 1 - core/server/web/module_tile_helpers.go | 10 -- core/server/web/settings_account.go | 13 +- core/server/web/settings_field_acl.go | 9 +- core/server/web/settings_hub.go | 114 +++++------------- core/server/web/testexports.go | 6 +- core/server/web/web_constants.go | 11 +- test/core/server/web/helpers_coverage_test.go | 6 +- test/core/server/web/settings_hub_test.go | 44 ++----- 9 files changed, 59 insertions(+), 155 deletions(-) diff --git a/core/engine/templates/settings_account_inner.html b/core/engine/templates/settings_account_inner.html index 130494a6..c4ef597d 100644 --- a/core/engine/templates/settings_account_inner.html +++ b/core/engine/templates/settings_account_inner.html @@ -10,7 +10,6 @@

Account security