@open-mercato/ui 0.4.11-develop.2635.9f9e474720 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/.turbo/turbo-build.log +2 -2
  2. package/AGENTS.md +28 -4
  3. package/agentic/standalone-guide.md +97 -0
  4. package/build.mjs +10 -6
  5. package/dist/backend/AppShell.js +15 -2
  6. package/dist/backend/AppShell.js.map +2 -2
  7. package/dist/backend/DataTable.js +22 -1
  8. package/dist/backend/DataTable.js.map +2 -2
  9. package/dist/backend/detail/CustomDataSection.js +1 -5
  10. package/dist/backend/detail/CustomDataSection.js.map +2 -2
  11. package/dist/backend/detail/InlineEditors.js +2 -5
  12. package/dist/backend/detail/InlineEditors.js.map +2 -2
  13. package/dist/backend/detail/NotesSection.js +2 -6
  14. package/dist/backend/detail/NotesSection.js.map +2 -2
  15. package/dist/backend/icons/lucideRegistry.generated.js +93 -3
  16. package/dist/backend/icons/lucideRegistry.generated.js.map +2 -2
  17. package/dist/backend/markdown/MarkdownContent.js +47 -4
  18. package/dist/backend/markdown/MarkdownContent.js.map +2 -2
  19. package/dist/portal/PortalShell.js +41 -11
  20. package/dist/portal/PortalShell.js.map +2 -2
  21. package/dist/portal/hooks/usePortalDashboardWidgets.js +40 -1
  22. package/dist/portal/hooks/usePortalDashboardWidgets.js.map +2 -2
  23. package/dist/portal/utils/nav.js +84 -0
  24. package/dist/portal/utils/nav.js.map +7 -0
  25. package/package.json +13 -9
  26. package/src/backend/AppShell.tsx +22 -2
  27. package/src/backend/DataTable.tsx +28 -5
  28. package/src/backend/__tests__/AppShell.test.tsx +67 -0
  29. package/src/backend/__tests__/FormHeader.test.tsx +0 -1
  30. package/src/backend/detail/CustomDataSection.tsx +1 -10
  31. package/src/backend/detail/InlineEditors.tsx +3 -15
  32. package/src/backend/detail/NotesSection.tsx +5 -14
  33. package/src/backend/icons/lucideRegistry.generated.tsx +93 -3
  34. package/src/backend/injection/__tests__/resolveInjectedIcon.test.tsx +7 -0
  35. package/src/backend/markdown/MarkdownContent.tsx +76 -6
  36. package/src/backend/section-page/types.ts +1 -0
  37. package/src/portal/PortalShell.tsx +43 -11
  38. package/src/portal/hooks/__tests__/usePortalDashboardWidgets.test.tsx +117 -0
  39. package/src/portal/hooks/usePortalDashboardWidgets.ts +55 -1
  40. package/src/portal/utils/__tests__/nav.test.ts +199 -0
  41. package/src/portal/utils/nav.ts +150 -0
@@ -1,3 +1,3 @@
1
- Generated lucide registry with 83 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
- Found 256 entry points
1
+ Generated lucide registry with 127 icons -> /home/runner/work/open-mercato/open-mercato/packages/ui/src/backend/icons/lucideRegistry.generated.tsx
2
+ Found 257 entry points
3
3
  ui built successfully
package/AGENTS.md CHANGED
@@ -249,19 +249,43 @@ function MyPage({ orgSlug }) {
249
249
  | `section:portal:sidebar` | Navigation sidebar |
250
250
  | `section:portal:user-menu` | User dropdown |
251
251
 
252
- ### Declarative Customer Auth in Page Metadata
252
+ ### Portal Page Metadata (REQUIRED)
253
253
 
254
- Portal pages can declare `requireCustomerAuth: true` and `requireCustomerFeatures` in their page metadata:
254
+ Every portal page (any page under `frontend/[orgSlug]/portal/...`) MUST ship a sibling `page.meta.ts`. The `(frontend)` catch-all server-side enforces `requireCustomerAuth` and `requireCustomerFeatures` from the route manifest, so omitting metadata silently disables access control on a page that should be guarded.
255
+
256
+ Authoring checklist for each portal page:
257
+ - Public pages (`login`, `signup`, `verify`, anonymous landing): set `navHidden: true`. Do not set `requireCustomerAuth`.
258
+ - Authenticated pages: set `requireCustomerAuth: true`.
259
+ - Pages that need feature gating: add `requireCustomerFeatures: ['portal.<feature>']`. Wildcard grants like `portal.*` are honored by the shared matcher.
260
+ - Pages that should appear in the portal sidebar: add a `nav` block (label + group). Pages without `nav` are routable but not auto-listed (correct for detail/edit pages).
255
261
 
256
262
  ```typescript
257
263
  // frontend/[orgSlug]/portal/orders/page.meta.ts
258
- export const metadata = {
264
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
265
+
266
+ export const metadata: PageMetadata = {
259
267
  requireCustomerAuth: true,
260
268
  requireCustomerFeatures: ['portal.orders.view'],
261
- navHidden: true,
269
+ titleKey: 'orders.nav.title',
270
+ title: 'Orders',
271
+ nav: {
272
+ label: 'Orders',
273
+ labelKey: 'orders.nav.title',
274
+ group: 'main', // 'main' | 'account'
275
+ order: 20,
276
+ icon: 'shopping-bag',
277
+ },
262
278
  }
279
+
280
+ export default metadata
263
281
  ```
264
282
 
283
+ The portal sidebar is built from these `nav` declarations by `/api/customer_accounts/portal/nav`, filtered by `CustomerRbacService` against the same `requireCustomerFeatures` that gates access. Granting the feature to a customer role is sufficient for the entry to appear — no separate menu-injection widget required.
284
+
285
+ For external links or items without a backing portal page, keep using `usePortalInjectedMenuItems` widgets.
286
+
287
+ Reference: see `packages/core/src/modules/portal/frontend/[orgSlug]/portal/{dashboard,profile,login,signup,verify}/page.meta.ts` for examples.
288
+
265
289
  ### Declarative Customer Role Features in setup.ts
266
290
 
267
291
  Modules can declare features to be merged into customer role ACLs:
@@ -206,6 +206,103 @@ import {
206
206
  | `PortalNotificationBell` | `t` | Header bell icon with unread badge |
207
207
  | `PortalNotificationPanel` | — | Notification dropdown panel |
208
208
 
209
+ ### Portal Page Structure
210
+
211
+ Every portal page is two files under `frontend/[orgSlug]/portal/<path>/`:
212
+
213
+ ```
214
+ page.tsx # Client component ("use client")
215
+ page.meta.ts # PageMetadata — access control + sidebar nav
216
+ ```
217
+
218
+ Minimal page:
219
+
220
+ ```tsx
221
+ "use client"
222
+ import { usePortalContext } from '@open-mercato/ui/portal/PortalContext'
223
+ import { PortalPageHeader } from '@open-mercato/ui/portal/components'
224
+
225
+ export default function MyPortalPage({ params }: { params: { orgSlug: string } }) {
226
+ const { auth } = usePortalContext()
227
+ const { user, resolvedFeatures } = auth
228
+ return <PortalPageHeader title="Orders" />
229
+ }
230
+ ```
231
+
232
+ Prefer `usePortalContext()` inside pages wrapped by `PortalLayoutShell` — it reads server-hydrated auth and avoids client loading flashes. Reach for `useCustomerAuth(orgSlug)` only when the server wrapper is unavailable.
233
+
234
+ Minimal `page.meta.ts`:
235
+
236
+ ```ts
237
+ import type { PageMetadata } from '@open-mercato/shared/modules/registry'
238
+
239
+ export const metadata: PageMetadata = {
240
+ requireCustomerAuth: true,
241
+ requireCustomerFeatures: ['portal.orders.view'],
242
+ titleKey: 'portal.orders.title',
243
+ title: 'Orders',
244
+ nav: { label: 'Orders', labelKey: 'portal.nav.orders', group: 'main', order: 20 },
245
+ }
246
+
247
+ export default metadata
248
+ ```
249
+
250
+ - Public pages (login, signup, verify, forgot/reset-password): omit `requireCustomerAuth`; set `navHidden: true`.
251
+ - Authenticated pages without sidebar presence (detail/create/edit): set `requireCustomerAuth: true`, **omit** `nav`.
252
+ - Sidebar-visible pages: include a `nav` block. Feature-gated pages are automatically hidden when the user lacks grants.
253
+
254
+ Reference: `packages/core/src/modules/portal/frontend/[orgSlug]/portal/{dashboard,profile}/page.{tsx,meta.ts}`.
255
+
256
+ ### Portal Feature-Gating Contract
257
+
258
+ Single source of truth: `requireCustomerFeatures` in `page.meta.ts`. The same list is enforced in three layers:
259
+
260
+ | Layer | Where | Effect |
261
+ |---|---|---|
262
+ | Page access | `apps/mercato/src/app/(frontend)/[...slug]/page.tsx` | Server-side gate via `CustomerRbacService.userHasAllFeatures()` — missing feature blocks render |
263
+ | Sidebar entry | `/api/customer_accounts/portal/nav` → `buildPortalNav()` at `packages/ui/src/portal/utils/nav.ts` | Same check — missing feature omits the entry |
264
+ | Injection widgets | `usePortalInjectedMenuItems` / `usePortalDashboardWidgets` | `/api/customer_accounts/portal/feature-check` + `hasAllFeatures()` — missing feature filters the widget |
265
+
266
+ Granting a customer role a feature (e.g. `portal.orders.view`) is sufficient to (a) reach the page, (b) see the sidebar entry, (c) see widgets gated by that feature. No separate menu-injection widget is required for sidebar presence when the page is backed by `page.meta.ts` with a `nav` block.
267
+
268
+ **MUST** resolve features via `hasAllFeatures` / `matchFeature` from `@open-mercato/shared/security/features`. Raw `Array.includes()` or `Set.has()` on feature arrays misses wildcards (`portal.*`) and is a bug.
269
+
270
+ Declare features in `acl.ts`; ship defaults per role via `defaultCustomerRoleFeatures` in `setup.ts`. Never rely on client-side checks alone as the access gate.
271
+
272
+ ### Portal SPA CSRF Posture
273
+
274
+ Dual cookies set by login (`packages/core/src/modules/customer_accounts/api/login.ts`):
275
+
276
+ | Cookie | Contents | TTL | Flags |
277
+ |---|---|---|---|
278
+ | `customer_auth_token` | Short-lived JWT | 8h | `httpOnly`, `sameSite: 'lax'`, `secure` in prod, `path: '/'` |
279
+ | `customer_session_token` | Raw session token (hashed at rest) | 30d (env: `CUSTOMER_SESSION_TTL_DAYS`) | same as above |
280
+
281
+ Primary CSRF defense: `SameSite=lax` + same-origin deployment. No explicit CSRF token — the browser blocks cross-origin POSTs.
282
+
283
+ Rules:
284
+ - Use `apiCall` for every write — it uses `credentials: 'same-origin'` and sets JSON headers.
285
+ - Never expose either cookie to JS. `httpOnly` is load-bearing; do not add companion cookies that mirror session state.
286
+ - Never accept cross-origin POSTs on portal routes. Cross-origin use cases are explicit exceptions: per-tenant origin allowlist + CSRF token + re-auth.
287
+ - `sameSite: 'lax'` lets GET navigations carry cookies — keep all state-changing side effects behind POST/PUT/PATCH/DELETE.
288
+ - Logout (`api/portal/logout.ts`) clears both cookies with `maxAge: 0`. Mirror this shape for any new logout-style endpoints.
289
+
290
+ Concurrent sessions are capped at `MAX_CUSTOMER_SESSIONS_PER_USER` (default 5) in `customerSessionService.createSession()`. New sessions above the cap soft-delete the oldest active session.
291
+
292
+ ### Portal XSS Discipline (Injected Widgets)
293
+
294
+ Third-party widgets render inside the authenticated portal and inherit user cookies. Enforce stricter discipline than first-party code because widgets load from arbitrary modules.
295
+
296
+ - **Forbidden**: `dangerouslySetInnerHTML` anywhere in portal injection widgets. Render structured data, not raw HTML.
297
+ - **Labels and user-facing text**: always through `useT()`; render as text children, never as HTML.
298
+ - **Icons**: Lucide components (`lucide-react`). No inline `<svg>` composed from user-controlled strings.
299
+ - **Asset URLs** (`src`, `href`, `action`, `srcDoc`): must not be user-controlled unless validated server-side against an allowlist.
300
+ - **No `eval`, `new Function`, `setTimeout(string)`**, or similar dynamic code paths.
301
+ - **Event-handler payloads** from SSE: validate shape (`isPortalBroadcastEvent` guards dispatch; never trust `event.data` to be well-formed without schema validation).
302
+ - **Styles**: no user-controlled strings in `style` props, CSS variables, or `className` built from untrusted input.
303
+
304
+ Prefer components that accept structured props over ones accepting `children` / `innerHTML` — the host keeps control of escaping.
305
+
209
306
  ### PortalShell Usage
210
307
 
211
308
  ```tsx
package/build.mjs CHANGED
@@ -9,13 +9,15 @@ const __dirname = dirname(fileURLToPath(import.meta.url))
9
9
  function normalizeKebabIconName(input) {
10
10
  const trimmed = input.trim()
11
11
  if (!trimmed) return ''
12
- if (!trimmed.includes('-') && !trimmed.includes('_') && !trimmed.includes(' ') && /[A-Z]/.test(trimmed)) {
13
- return trimmed
12
+ const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
13
+ if (!withoutPrefix) return ''
14
+ if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
15
+ return withoutPrefix
14
16
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
15
17
  .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
16
18
  .toLowerCase()
17
19
  }
18
- return trimmed
20
+ return withoutPrefix
19
21
  .replace(/[_\s]+/g, '-')
20
22
  .replace(/-+/g, '-')
21
23
  .toLowerCase()
@@ -109,13 +111,15 @@ ${registryEntries ? `${registryEntries}\n` : ''}}
109
111
  function normalizeKebabIconName(input: string): string {
110
112
  const trimmed = input.trim()
111
113
  if (!trimmed) return ''
112
- if (!trimmed.includes('-') && !trimmed.includes('_') && !trimmed.includes(' ') && /[A-Z]/.test(trimmed)) {
113
- return trimmed
114
+ const withoutPrefix = trimmed.startsWith('lucide:') ? trimmed.slice('lucide:'.length) : trimmed
115
+ if (!withoutPrefix) return ''
116
+ if (!withoutPrefix.includes('-') && !withoutPrefix.includes('_') && !withoutPrefix.includes(' ') && /[A-Z]/.test(withoutPrefix)) {
117
+ return withoutPrefix
114
118
  .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
115
119
  .replace(/([A-Z])([A-Z][a-z])/g, '$1-$2')
116
120
  .toLowerCase()
117
121
  }
118
- return trimmed
122
+ return withoutPrefix
119
123
  .replace(/[_\\s]+/g, '-')
120
124
  .replace(/-+/g, '-')
121
125
  .toLowerCase()
@@ -46,6 +46,7 @@ function convertInjectedMenuItemToSidebarItem(item, title) {
46
46
  title,
47
47
  defaultTitle: title,
48
48
  icon: resolveInjectedIcon(item.icon) ?? void 0,
49
+ iconName: item.icon,
49
50
  enabled: true,
50
51
  hidden: false,
51
52
  pageContext: "main"
@@ -200,8 +201,12 @@ function resolveItemKey(item) {
200
201
  function SerializedIcon({ markup }) {
201
202
  return /* @__PURE__ */ jsx("span", { "aria-hidden": "true", dangerouslySetInnerHTML: { __html: markup } });
202
203
  }
203
- function renderIcon(icon, iconMarkup, fallback) {
204
+ function renderIcon(icon, iconName, iconMarkup, fallback) {
204
205
  if (icon) return icon;
206
+ if (iconName) {
207
+ const resolved = resolveInjectedIcon(iconName);
208
+ if (resolved) return resolved;
209
+ }
205
210
  if (iconMarkup) return /* @__PURE__ */ jsx(SerializedIcon, { markup: iconMarkup });
206
211
  return fallback;
207
212
  }
@@ -657,6 +662,7 @@ function AppShellBody({ productName, email, groups, rightHeaderSlot, children, s
657
662
  isActive && /* @__PURE__ */ jsx("span", { className: "absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" }),
658
663
  /* @__PURE__ */ jsx("span", { className: `flex items-center justify-center shrink-0 ${compact ? "" : "text-muted-foreground"}`, children: renderIcon(
659
664
  item.icon,
665
+ item.iconName,
660
666
  item.iconMarkup,
661
667
  item.href.includes("/backend/entities/user/") && item.href.endsWith("/records") ? DataTableIcon : DefaultIcon
662
668
  ) }),
@@ -992,7 +998,12 @@ function AppShellBody({ productName, email, groups, rightHeaderSlot, children, s
992
998
  onClick: () => setMobileOpen(false),
993
999
  children: [
994
1000
  isParentActive ? /* @__PURE__ */ jsx("span", { className: "absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" }) : null,
995
- /* @__PURE__ */ jsx("span", { className: `flex items-center justify-center shrink-0 ${compact ? "" : "text-muted-foreground"}`, children: renderIcon(i.icon, i.iconMarkup, DefaultIcon) }),
1001
+ /* @__PURE__ */ jsx("span", { className: `flex items-center justify-center shrink-0 ${compact ? "" : "text-muted-foreground"}`, children: renderIcon(
1002
+ i.icon,
1003
+ i.iconName,
1004
+ i.iconMarkup,
1005
+ DefaultIcon
1006
+ ) }),
996
1007
  !compact && /* @__PURE__ */ jsx("span", { children: i.title })
997
1008
  ]
998
1009
  }
@@ -1013,6 +1024,7 @@ function AppShellBody({ productName, email, groups, rightHeaderSlot, children, s
1013
1024
  childActive ? /* @__PURE__ */ jsx("span", { className: "absolute left-0 top-1 bottom-1 w-0.5 rounded bg-foreground" }) : null,
1014
1025
  /* @__PURE__ */ jsx("span", { className: `flex items-center justify-center shrink-0 ${compact ? "" : "text-muted-foreground"}`, children: renderIcon(
1015
1026
  c.icon,
1027
+ c.iconName,
1016
1028
  c.iconMarkup,
1017
1029
  c.href.includes("/backend/entities/user/") && c.href.endsWith("/records") ? DataTableIcon : DefaultIcon
1018
1030
  ) }),
@@ -1257,6 +1269,7 @@ AppShell.cloneGroups = function cloneGroups(groups) {
1257
1269
  title: item.title,
1258
1270
  defaultTitle: item.defaultTitle,
1259
1271
  icon: item.icon,
1272
+ iconName: item.iconName,
1260
1273
  iconMarkup: item.iconMarkup,
1261
1274
  enabled: item.enabled,
1262
1275
  hidden: item.hidden,