@open-mercato/ui 0.5.1-develop.2638.59e6e26f46 → 0.5.1-develop.2657.a01847a9fa
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.
- package/.turbo/turbo-build.log +1 -1
- package/AGENTS.md +28 -4
- package/agentic/standalone-guide.md +97 -0
- package/dist/portal/PortalShell.js +41 -11
- package/dist/portal/PortalShell.js.map +2 -2
- package/dist/portal/hooks/usePortalDashboardWidgets.js +40 -1
- package/dist/portal/hooks/usePortalDashboardWidgets.js.map +2 -2
- package/dist/portal/utils/nav.js +84 -0
- package/dist/portal/utils/nav.js.map +7 -0
- package/package.json +3 -3
- package/src/portal/PortalShell.tsx +43 -11
- package/src/portal/hooks/__tests__/usePortalDashboardWidgets.test.tsx +117 -0
- package/src/portal/hooks/usePortalDashboardWidgets.ts +55 -1
- package/src/portal/utils/__tests__/nav.test.ts +199 -0
- package/src/portal/utils/nav.ts +150 -0
package/.turbo/turbo-build.log
CHANGED
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
|
-
###
|
|
252
|
+
### Portal Page Metadata (REQUIRED)
|
|
253
253
|
|
|
254
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
-
import { useState, useCallback, useMemo } from "react";
|
|
3
|
+
import { useEffect, useState, useCallback, useMemo } from "react";
|
|
4
4
|
import Image from "next/image";
|
|
5
5
|
import Link from "next/link";
|
|
6
6
|
import { usePathname } from "next/navigation";
|
|
@@ -12,6 +12,7 @@ import { usePortalEventBridge } from "./hooks/usePortalEventBridge.js";
|
|
|
12
12
|
import { mergeMenuItems } from "../backend/injection/mergeMenuItems.js";
|
|
13
13
|
import { PortalNotificationBell } from "./components/PortalNotificationBell.js";
|
|
14
14
|
import { usePortalContext } from "./PortalContext.js";
|
|
15
|
+
import { apiCall } from "../backend/utils/apiCall.js";
|
|
15
16
|
const PORTAL_SHELL_HANDLE = "page:portal:layout";
|
|
16
17
|
const PORTAL_HEADER_HANDLE = "section:portal:header";
|
|
17
18
|
const PORTAL_FOOTER_HANDLE = "section:portal:footer";
|
|
@@ -101,24 +102,53 @@ function PortalShell({
|
|
|
101
102
|
const portalHome = orgSlug ? `/${orgSlug}/portal` : "/portal";
|
|
102
103
|
const loginHref = orgSlug ? `/${orgSlug}/portal/login` : "/portal/login";
|
|
103
104
|
const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : "/portal/signup";
|
|
104
|
-
const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : "/portal/dashboard";
|
|
105
|
-
const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : "/portal/profile";
|
|
106
105
|
const headerTitle = orgName || t("portal.title", "Customer Portal");
|
|
107
106
|
const closeMobile = useCallback(() => setMobileOpen(false), []);
|
|
107
|
+
const [autoNavGroups, setAutoNavGroups] = useState([]);
|
|
108
|
+
useEffect(() => {
|
|
109
|
+
if (!authenticated) {
|
|
110
|
+
setAutoNavGroups([]);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
let cancelled = false;
|
|
114
|
+
const load = async () => {
|
|
115
|
+
try {
|
|
116
|
+
const { ok, result } = await apiCall(
|
|
117
|
+
"/api/customer_accounts/portal/nav"
|
|
118
|
+
);
|
|
119
|
+
if (cancelled || !ok || !result?.ok) return;
|
|
120
|
+
setAutoNavGroups(Array.isArray(result.groups) ? result.groups : []);
|
|
121
|
+
} catch {
|
|
122
|
+
if (!cancelled) setAutoNavGroups([]);
|
|
123
|
+
}
|
|
124
|
+
};
|
|
125
|
+
void load();
|
|
126
|
+
return () => {
|
|
127
|
+
cancelled = true;
|
|
128
|
+
};
|
|
129
|
+
}, [authenticated]);
|
|
108
130
|
const mergedNavItems = useMemo(() => {
|
|
109
131
|
if (!authenticated) return [];
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
132
|
+
const discovered = autoNavGroups.find((g) => g.id === "main")?.items ?? [];
|
|
133
|
+
const builtIn = discovered.map((item) => ({
|
|
134
|
+
id: item.id,
|
|
135
|
+
labelKey: item.labelKey,
|
|
136
|
+
label: item.label,
|
|
137
|
+
href: item.href
|
|
138
|
+
}));
|
|
113
139
|
return mergeMenuItems(builtIn, injectedMainItems);
|
|
114
|
-
}, [authenticated,
|
|
140
|
+
}, [authenticated, autoNavGroups, injectedMainItems]);
|
|
115
141
|
const mergedAccountItems = useMemo(() => {
|
|
116
142
|
if (!authenticated) return [];
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
|
|
143
|
+
const discovered = autoNavGroups.find((g) => g.id === "account")?.items ?? [];
|
|
144
|
+
const builtIn = discovered.map((item) => ({
|
|
145
|
+
id: item.id,
|
|
146
|
+
labelKey: item.labelKey,
|
|
147
|
+
label: item.label,
|
|
148
|
+
href: item.href
|
|
149
|
+
}));
|
|
120
150
|
return mergeMenuItems(builtIn, injectedAccountItems);
|
|
121
|
-
}, [authenticated,
|
|
151
|
+
}, [authenticated, autoNavGroups, injectedAccountItems]);
|
|
122
152
|
if (!authenticated) {
|
|
123
153
|
return /* @__PURE__ */ jsxs("div", { className: "flex min-h-svh flex-col bg-background", "data-portal-handle": PORTAL_SHELL_HANDLE, children: [
|
|
124
154
|
/* @__PURE__ */ jsx("header", { className: "sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80", "data-portal-handle": PORTAL_HEADER_HANDLE, children: /* @__PURE__ */ jsxs("div", { className: "mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6", children: [
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/portal/PortalShell.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport { type ReactNode, useState, useCallback, useMemo, useContext } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n const authenticated = authenticatedProp ?? !!user\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : '/portal/dashboard'\n const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : '/portal/profile'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const builtIn = [\n { id: 'portal-dashboard', labelKey: 'portal.nav.dashboard', href: dashboardHref },\n ]\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, dashboardHref, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const builtIn = [\n { id: 'portal-profile', labelKey: 'portal.nav.profile', href: profileHref },\n ]\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, profileHref, injectedAccountItems])\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={28} height={28} className=\"\" priority />\n <span className=\"text-[15px] font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-[13px]\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-[13px]\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src=\"/open-mercato.svg\" alt=\"\" width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={22} height={22} className=\"\" />\n <span className=\"text-[14px] font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n\n {mergedAccountItems.length > 0 ? (\n <div className=\"mt-8\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-[13px] font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-[11px] text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-50 lg:hidden\">\n <div className=\"absolute inset-0 bg-black/30 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-[11px] text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport { type ReactNode, useEffect, useState, useCallback, useMemo, useContext } from 'react'\nimport Image from 'next/image'\nimport Link from 'next/link'\nimport { usePathname } from 'next/navigation'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { Button } from '../primitives/button'\nimport { IconButton } from '../primitives/icon-button'\nimport { usePortalInjectedMenuItems } from './hooks/usePortalInjectedMenuItems'\nimport { usePortalEventBridge } from './hooks/usePortalEventBridge'\nimport { mergeMenuItems } from '../backend/injection/mergeMenuItems'\nimport type { MergedMenuItem } from '../backend/injection/mergeMenuItems'\nimport { PortalNotificationBell } from './components/PortalNotificationBell'\nimport { usePortalContext } from './PortalContext'\nimport { apiCall } from '../backend/utils/apiCall'\nimport type { PortalNavGroup } from './utils/nav'\n\n// Component replacement handle IDs (FROZEN once shipped)\nexport const PORTAL_SHELL_HANDLE = 'page:portal:layout'\nexport const PORTAL_HEADER_HANDLE = 'section:portal:header'\nexport const PORTAL_FOOTER_HANDLE = 'section:portal:footer'\nexport const PORTAL_SIDEBAR_HANDLE = 'section:portal:sidebar'\nexport const PORTAL_USER_MENU_HANDLE = 'section:portal:user-menu'\n\nexport type PortalShellProps = {\n children: ReactNode\n /** Override orgSlug (used on public pages without context) */\n orgSlug?: string\n /** Override organization name (used on public pages without context) */\n organizationName?: string\n /** Whether to show authenticated layout. Auto-detected from context when omitted. */\n authenticated?: boolean\n /** Logout handler. Auto-provided from context when omitted. */\n onLogout?: () => void\n enableEventBridge?: boolean\n /** Override user name. Auto-read from context when omitted. */\n userName?: string\n /** Override user email. Auto-read from context when omitted. */\n userEmail?: string\n}\n\nfunction PortalEventBridgeMount() {\n usePortalEventBridge()\n return null\n}\n\n/* ---- Inline SVG icons ---- */\n\nfunction MenuIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <line x1=\"4\" x2=\"20\" y1=\"12\" y2=\"12\" /><line x1=\"4\" x2=\"20\" y1=\"6\" y2=\"6\" /><line x1=\"4\" x2=\"20\" y1=\"18\" y2=\"18\" />\n </svg>\n )\n}\n\nfunction XIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M18 6 6 18\" /><path d=\"m6 6 12 12\" />\n </svg>\n )\n}\n\nfunction LogOutIcon({ className }: { className?: string }) {\n return (\n <svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 24 24\" fill=\"none\" stroke=\"currentColor\" strokeWidth=\"1.5\" strokeLinecap=\"round\" strokeLinejoin=\"round\" className={className}>\n <path d=\"M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4\" /><polyline points=\"16 17 21 12 16 7\" /><line x1=\"21\" x2=\"9\" y1=\"12\" y2=\"12\" />\n </svg>\n )\n}\n\n/* ---- Sidebar nav item ---- */\n\nfunction SidebarNavItem({\n item,\n active,\n t,\n onClick,\n}: {\n item: MergedMenuItem\n active: boolean\n t: (key: string, fallback?: string) => string\n onClick?: () => void\n}) {\n const label = item.labelKey ? t(item.labelKey, item.label) : item.label\n if (!label) return null\n\n const cls = [\n 'flex items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] font-medium transition-colors',\n active\n ? 'bg-foreground text-background'\n : 'text-muted-foreground hover:bg-muted hover:text-foreground',\n ].join(' ')\n\n if (item.href) {\n return (\n <Link href={item.href} className={cls} data-menu-item-id={item.id} onClick={onClick}>\n {label}\n </Link>\n )\n }\n if (item.onClick) {\n return (\n <button type=\"button\" className={cls} data-menu-item-id={item.id} onClick={() => { item.onClick?.(); onClick?.() }}>\n {label}\n </button>\n )\n }\n return null\n}\n\n/* ---- User initials avatar ---- */\n\nfunction UserAvatar({ name, className }: { name?: string; className?: string }) {\n const initials = name\n ? name.split(' ').map((w) => w[0]).slice(0, 2).join('').toUpperCase()\n : '?'\n return (\n <div className={`flex items-center justify-center rounded-full bg-foreground text-[11px] font-semibold text-background ${className ?? 'size-8'}`}>\n {initials}\n </div>\n )\n}\n\n/* ---- Try reading from PortalContext ---- */\n\nfunction useOptionalPortalContext() {\n try {\n return usePortalContext()\n } catch {\n return null\n }\n}\n\n/* ================================================================== */\n/* PortalShell */\n/* ================================================================== */\n\n/**\n * Portal layout shell.\n *\n * When a `PortalProvider` is mounted in a parent layout, PortalShell reads\n * auth/tenant state from context \u2014 no re-fetching on navigation. Props are\n * used as overrides or for public pages that don't have a context.\n */\nexport function PortalShell({\n children,\n orgSlug: orgSlugProp,\n organizationName: orgNameProp,\n authenticated: authenticatedProp,\n onLogout: onLogoutProp,\n enableEventBridge = false,\n userName: userNameProp,\n userEmail: userEmailProp,\n}: PortalShellProps) {\n const t = useT()\n const pathname = usePathname()\n const [mobileOpen, setMobileOpen] = useState(false)\n\n // Read from context when available (persists across navigations)\n const portalCtx = useOptionalPortalContext()\n\n // Resolve values: context takes priority, props are fallback/override\n const orgSlug = portalCtx?.orgSlug ?? orgSlugProp\n const orgName = portalCtx?.tenant.organizationName ?? orgNameProp\n const user = portalCtx?.auth.user ?? null\n const authenticated = authenticatedProp ?? !!user\n const onLogout = onLogoutProp ?? portalCtx?.auth.logout\n const userName = userNameProp ?? user?.displayName\n const userEmail = userEmailProp ?? user?.email\n\n const { items: injectedMainItems } = usePortalInjectedMenuItems('menu:portal:sidebar:main')\n const { items: injectedAccountItems } = usePortalInjectedMenuItems('menu:portal:sidebar:account')\n\n const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'\n const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'\n const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'\n // Always use the resolved organization name from the database.\n // Fall back to the generic portal title \u2014 never display the raw slug.\n const headerTitle = orgName || t('portal.title', 'Customer Portal')\n\n const closeMobile = useCallback(() => setMobileOpen(false), [])\n\n const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])\n useEffect(() => {\n if (!authenticated) {\n setAutoNavGroups([])\n return\n }\n let cancelled = false\n const load = async () => {\n try {\n const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(\n '/api/customer_accounts/portal/nav',\n )\n if (cancelled || !ok || !result?.ok) return\n setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])\n } catch {\n if (!cancelled) setAutoNavGroups([])\n }\n }\n void load()\n return () => {\n cancelled = true\n }\n }, [authenticated])\n\n const mergedNavItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedMainItems)\n }, [authenticated, autoNavGroups, injectedMainItems])\n\n const mergedAccountItems = useMemo(() => {\n if (!authenticated) return []\n const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []\n const builtIn = discovered.map((item) => ({\n id: item.id,\n labelKey: item.labelKey,\n label: item.label,\n href: item.href,\n }))\n return mergeMenuItems(builtIn, injectedAccountItems)\n }, [authenticated, autoNavGroups, injectedAccountItems])\n\n /* ---- PUBLIC LAYOUT ---- */\n if (!authenticated) {\n return (\n <div className=\"flex min-h-svh flex-col bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n <header className=\"sticky top-0 z-40 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"mx-auto flex h-16 w-full max-w-screen-lg items-center justify-between px-6\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={28} height={28} className=\"\" priority />\n <span className=\"text-[15px] font-semibold tracking-tight\">{headerTitle}</span>\n </Link>\n <nav aria-label=\"Primary\" className=\"flex items-center gap-1\">\n <Button asChild variant=\"ghost\" size=\"sm\" className=\"text-[13px]\">\n <Link href={loginHref}>{t('portal.nav.login', 'Log In')}</Link>\n </Button>\n <Button asChild size=\"sm\" className=\"rounded-lg text-[13px]\">\n <Link href={signupHref}>{t('portal.nav.signup', 'Sign Up')}</Link>\n </Button>\n </nav>\n </div>\n </header>\n\n <main className=\"flex-1\">\n <div className=\"mx-auto flex w-full max-w-screen-lg flex-col gap-8 px-6 py-12 sm:py-20\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <div className=\"mx-auto flex w-full max-w-screen-lg items-center justify-between px-6 py-6\">\n <Link href={portalHome} className=\"flex items-center gap-2 text-muted-foreground transition hover:text-foreground\">\n <Image src=\"/open-mercato.svg\" alt=\"\" width={20} height={20} className=\"\" />\n <span className=\"text-sm font-medium text-foreground\">{headerTitle}</span>\n </Link>\n <p className=\"text-xs text-muted-foreground/60\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </div>\n </footer>\n </div>\n )\n }\n\n /* ---- AUTHENTICATED LAYOUT ---- */\n\n const sidebarContent = (\n <div className=\"flex h-full flex-col\" data-portal-handle={PORTAL_SIDEBAR_HANDLE}>\n <div className=\"flex h-16 items-center gap-2.5 border-b px-5\">\n <Link href={portalHome} className=\"flex items-center gap-2.5 text-foreground transition hover:opacity-80\" aria-label={headerTitle}>\n <Image src=\"/open-mercato.svg\" alt=\"\" width={22} height={22} className=\"\" />\n <span className=\"text-[14px] font-semibold tracking-tight truncate\">{headerTitle}</span>\n </Link>\n </div>\n\n <nav aria-label=\"Portal navigation\" className=\"flex-1 overflow-y-auto px-3 py-5\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.home', 'Portal')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedNavItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n\n {mergedAccountItems.length > 0 ? (\n <div className=\"mt-8\">\n <p className=\"mb-2 px-3 text-[10px] font-semibold uppercase tracking-[0.12em] text-muted-foreground/50\">\n {t('portal.nav.account', 'Account')}\n </p>\n <div className=\"flex flex-col gap-0.5\">\n {mergedAccountItems.map((item) => (\n <SidebarNavItem\n key={item.id}\n item={item}\n active={!!item.href && pathname.startsWith(item.href)}\n t={t}\n onClick={closeMobile}\n />\n ))}\n </div>\n </div>\n ) : null}\n </nav>\n\n <div className=\"border-t px-3 py-3\">\n <div className=\"flex items-center gap-2.5 rounded-lg px-3 py-2\">\n <UserAvatar name={userName} className=\"size-8\" />\n <div className=\"min-w-0 flex-1\">\n {userName ? (\n <p className=\"truncate text-[13px] font-medium leading-tight\">{userName}</p>\n ) : (\n <div className=\"h-4 w-24 animate-pulse rounded bg-muted\" />\n )}\n {userEmail ? (\n <p className=\"truncate text-[11px] text-muted-foreground\">{userEmail}</p>\n ) : (\n <div className=\"mt-1 h-3 w-32 animate-pulse rounded bg-muted\" />\n )}\n </div>\n </div>\n <button\n type=\"button\"\n onClick={onLogout}\n className=\"mt-0.5 flex w-full items-center gap-2.5 rounded-lg px-3 py-2 text-[13px] text-muted-foreground transition-colors hover:bg-muted hover:text-foreground\"\n data-portal-handle={PORTAL_USER_MENU_HANDLE}\n data-menu-item-id=\"portal-logout\"\n >\n <LogOutIcon className=\"size-4\" />\n {t('portal.nav.logout', 'Log Out')}\n </button>\n </div>\n </div>\n )\n\n return (\n <div className=\"flex min-h-svh bg-background\" data-portal-handle={PORTAL_SHELL_HANDLE}>\n {enableEventBridge ? <PortalEventBridgeMount /> : null}\n\n <aside className=\"hidden w-[240px] shrink-0 border-r lg:block\">\n {sidebarContent}\n </aside>\n\n {mobileOpen ? (\n <div className=\"fixed inset-0 z-50 lg:hidden\">\n <div className=\"absolute inset-0 bg-black/30 backdrop-blur-sm\" onClick={closeMobile} />\n <aside className=\"relative z-10 h-full w-[280px] bg-background shadow-2xl\">\n <div className=\"absolute right-3 top-4 z-20\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={closeMobile} aria-label=\"Close menu\">\n <XIcon className=\"size-4\" />\n </IconButton>\n </div>\n {sidebarContent}\n </aside>\n </div>\n ) : null}\n\n <div className=\"flex min-w-0 flex-1 flex-col\">\n <header className=\"flex h-16 items-center justify-between border-b px-4 lg:px-8\" data-portal-handle={PORTAL_HEADER_HANDLE}>\n <div className=\"flex items-center gap-3\">\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => setMobileOpen(true)} className=\"lg:hidden\" aria-label=\"Open menu\">\n <MenuIcon className=\"size-5\" />\n </IconButton>\n </div>\n <div className=\"flex items-center gap-3\">\n <PortalNotificationBell t={t} />\n </div>\n </header>\n\n <main className=\"flex-1 overflow-y-auto\">\n <div className=\"w-full px-4 py-6 lg:px-8 lg:py-8\">\n {children}\n </div>\n </main>\n\n <footer className=\"border-t px-4 py-4 lg:px-8\" data-portal-handle={PORTAL_FOOTER_HANDLE}>\n <p className=\"text-[11px] text-muted-foreground/50\">\n {t('portal.footer.copyright', '\\u00A9 {year} All rights reserved.', { year: new Date().getFullYear() })}\n </p>\n </footer>\n </div>\n </div>\n )\n}\n\nexport default PortalShell\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -1,10 +1,37 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import * as React from "react";
|
|
3
3
|
import { loadInjectionWidgetsForSpot } from "@open-mercato/shared/modules/widgets/injection-loader";
|
|
4
|
+
import { hasAllFeatures } from "@open-mercato/shared/security/features";
|
|
5
|
+
import { apiCall } from "../../backend/utils/apiCall.js";
|
|
6
|
+
function collectRequiredFeatures(widgets) {
|
|
7
|
+
const set = /* @__PURE__ */ new Set();
|
|
8
|
+
for (const widget of widgets) {
|
|
9
|
+
for (const feature of widget.metadata.features ?? []) {
|
|
10
|
+
if (!feature || feature.trim().length === 0) continue;
|
|
11
|
+
set.add(feature);
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
return Array.from(set);
|
|
15
|
+
}
|
|
16
|
+
async function readPortalGrantedFeatures(features) {
|
|
17
|
+
if (features.length === 0) return /* @__PURE__ */ new Set();
|
|
18
|
+
try {
|
|
19
|
+
const { ok, result: data } = await apiCall("/api/customer_accounts/portal/feature-check", {
|
|
20
|
+
method: "POST",
|
|
21
|
+
headers: { "content-type": "application/json" },
|
|
22
|
+
body: JSON.stringify({ features })
|
|
23
|
+
});
|
|
24
|
+
if (!ok || !data?.ok) return /* @__PURE__ */ new Set();
|
|
25
|
+
return new Set(data.granted ?? []);
|
|
26
|
+
} catch {
|
|
27
|
+
return /* @__PURE__ */ new Set();
|
|
28
|
+
}
|
|
29
|
+
}
|
|
4
30
|
function usePortalDashboardWidgets(spotId) {
|
|
5
31
|
const [widgets, setWidgets] = React.useState([]);
|
|
6
32
|
const [isLoading, setIsLoading] = React.useState(true);
|
|
7
33
|
const [error, setError] = React.useState(null);
|
|
34
|
+
const [grantedFeatures, setGrantedFeatures] = React.useState(/* @__PURE__ */ new Set());
|
|
8
35
|
React.useEffect(() => {
|
|
9
36
|
let mounted = true;
|
|
10
37
|
const load = async () => {
|
|
@@ -15,6 +42,10 @@ function usePortalDashboardWidgets(spotId) {
|
|
|
15
42
|
if (!mounted) return;
|
|
16
43
|
const uiWidgets = loaded.filter((w) => typeof w.Widget === "function");
|
|
17
44
|
setWidgets(uiWidgets);
|
|
45
|
+
const required = collectRequiredFeatures(uiWidgets);
|
|
46
|
+
const granted = await readPortalGrantedFeatures(required);
|
|
47
|
+
if (!mounted) return;
|
|
48
|
+
setGrantedFeatures(granted);
|
|
18
49
|
} catch (loadError) {
|
|
19
50
|
if (!mounted) return;
|
|
20
51
|
console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError);
|
|
@@ -29,7 +60,15 @@ function usePortalDashboardWidgets(spotId) {
|
|
|
29
60
|
mounted = false;
|
|
30
61
|
};
|
|
31
62
|
}, [spotId]);
|
|
32
|
-
|
|
63
|
+
const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures]);
|
|
64
|
+
const visibleWidgets = React.useMemo(
|
|
65
|
+
() => widgets.filter((widget) => {
|
|
66
|
+
const required = widget.metadata.features ?? [];
|
|
67
|
+
return required.length === 0 || hasAllFeatures(grantedFeatureList, required);
|
|
68
|
+
}),
|
|
69
|
+
[widgets, grantedFeatureList]
|
|
70
|
+
);
|
|
71
|
+
return { widgets: visibleWidgets, isLoading, error };
|
|
33
72
|
}
|
|
34
73
|
export {
|
|
35
74
|
usePortalDashboardWidgets
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/portal/hooks/usePortalDashboardWidgets.ts"],
|
|
4
|
-
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'\nimport { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'\n\n/**\n * Loads UI injection widgets (with Widget component) for a portal spot.\n *\n * Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),\n * this hook loads widgets that export a `Widget` React component \u2014 suitable for\n * portal dashboard sections and other UI injection spots.\n */\nexport function usePortalDashboardWidgets(spotId: InjectionSpotId): {\n widgets: LoadedInjectionWidget[]\n isLoading: boolean\n error: string | null\n} {\n const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n\n React.useEffect(() => {\n let mounted = true\n const load = async () => {\n try {\n setIsLoading(true)\n setError(null)\n const loaded = await loadInjectionWidgetsForSpot(spotId)\n if (!mounted) return\n // Only keep widgets that have a Widget component\n const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')\n setWidgets(uiWidgets)\n } catch (loadError) {\n if (!mounted) return\n console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)\n setError(loadError instanceof Error ? loadError.message : String(loadError))\n setWidgets([])\n } finally {\n if (mounted) setIsLoading(false)\n }\n }\n void load()\n return () => {\n mounted = false\n }\n }, [spotId])\n\n return { widgets, isLoading, error }\n}\n"],
|
|
5
|
-
"mappings": ";AAEA,YAAY,WAAW;AAEvB,SAAS,mCAA+D;
|
|
4
|
+
"sourcesContent": ["'use client'\n\nimport * as React from 'react'\nimport type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'\nimport { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\nimport { apiCall } from '../../backend/utils/apiCall'\n\ntype PortalFeatureCheckResponse = {\n ok: boolean\n granted?: string[]\n}\n\nfunction collectRequiredFeatures(widgets: LoadedInjectionWidget[]): string[] {\n const set = new Set<string>()\n for (const widget of widgets) {\n for (const feature of widget.metadata.features ?? []) {\n if (!feature || feature.trim().length === 0) continue\n set.add(feature)\n }\n }\n return Array.from(set)\n}\n\nasync function readPortalGrantedFeatures(features: string[]): Promise<Set<string>> {\n if (features.length === 0) return new Set()\n try {\n const { ok, result: data } = await apiCall<PortalFeatureCheckResponse>('/api/customer_accounts/portal/feature-check', {\n method: 'POST',\n headers: { 'content-type': 'application/json' },\n body: JSON.stringify({ features }),\n })\n if (!ok || !data?.ok) return new Set()\n return new Set(data.granted ?? [])\n } catch {\n return new Set()\n }\n}\n\n/**\n * Loads UI injection widgets (with Widget component) for a portal spot.\n *\n * Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),\n * this hook loads widgets that export a `Widget` React component \u2014 suitable for\n * portal dashboard sections and other UI injection spots.\n *\n * Feature gating: widgets declaring `metadata.features` are filtered against the\n * authenticated customer's grants resolved via\n * `/api/customer_accounts/portal/feature-check`. Wildcard grants (`portal.*`) resolve\n * through the shared matcher.\n */\nexport function usePortalDashboardWidgets(spotId: InjectionSpotId): {\n widgets: LoadedInjectionWidget[]\n isLoading: boolean\n error: string | null\n} {\n const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])\n const [isLoading, setIsLoading] = React.useState(true)\n const [error, setError] = React.useState<string | null>(null)\n const [grantedFeatures, setGrantedFeatures] = React.useState<Set<string>>(new Set())\n\n React.useEffect(() => {\n let mounted = true\n const load = async () => {\n try {\n setIsLoading(true)\n setError(null)\n const loaded = await loadInjectionWidgetsForSpot(spotId)\n if (!mounted) return\n // Only keep widgets that have a Widget component\n const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')\n setWidgets(uiWidgets)\n const required = collectRequiredFeatures(uiWidgets)\n const granted = await readPortalGrantedFeatures(required)\n if (!mounted) return\n setGrantedFeatures(granted)\n } catch (loadError) {\n if (!mounted) return\n console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)\n setError(loadError instanceof Error ? loadError.message : String(loadError))\n setWidgets([])\n } finally {\n if (mounted) setIsLoading(false)\n }\n }\n void load()\n return () => {\n mounted = false\n }\n }, [spotId])\n\n const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures])\n\n const visibleWidgets = React.useMemo(\n () =>\n widgets.filter((widget) => {\n const required = widget.metadata.features ?? []\n return required.length === 0 || hasAllFeatures(grantedFeatureList, required)\n }),\n [widgets, grantedFeatureList],\n )\n\n return { widgets: visibleWidgets, isLoading, error }\n}\n"],
|
|
5
|
+
"mappings": ";AAEA,YAAY,WAAW;AAEvB,SAAS,mCAA+D;AACxE,SAAS,sBAAsB;AAC/B,SAAS,eAAe;AAOxB,SAAS,wBAAwB,SAA4C;AAC3E,QAAM,MAAM,oBAAI,IAAY;AAC5B,aAAW,UAAU,SAAS;AAC5B,eAAW,WAAW,OAAO,SAAS,YAAY,CAAC,GAAG;AACpD,UAAI,CAAC,WAAW,QAAQ,KAAK,EAAE,WAAW,EAAG;AAC7C,UAAI,IAAI,OAAO;AAAA,IACjB;AAAA,EACF;AACA,SAAO,MAAM,KAAK,GAAG;AACvB;AAEA,eAAe,0BAA0B,UAA0C;AACjF,MAAI,SAAS,WAAW,EAAG,QAAO,oBAAI,IAAI;AAC1C,MAAI;AACF,UAAM,EAAE,IAAI,QAAQ,KAAK,IAAI,MAAM,QAAoC,+CAA+C;AAAA,MACpH,QAAQ;AAAA,MACR,SAAS,EAAE,gBAAgB,mBAAmB;AAAA,MAC9C,MAAM,KAAK,UAAU,EAAE,SAAS,CAAC;AAAA,IACnC,CAAC;AACD,QAAI,CAAC,MAAM,CAAC,MAAM,GAAI,QAAO,oBAAI,IAAI;AACrC,WAAO,IAAI,IAAI,KAAK,WAAW,CAAC,CAAC;AAAA,EACnC,QAAQ;AACN,WAAO,oBAAI,IAAI;AAAA,EACjB;AACF;AAcO,SAAS,0BAA0B,QAIxC;AACA,QAAM,CAAC,SAAS,UAAU,IAAI,MAAM,SAAkC,CAAC,CAAC;AACxE,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,IAAI;AACrD,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAwB,IAAI;AAC5D,QAAM,CAAC,iBAAiB,kBAAkB,IAAI,MAAM,SAAsB,oBAAI,IAAI,CAAC;AAEnF,QAAM,UAAU,MAAM;AACpB,QAAI,UAAU;AACd,UAAM,OAAO,YAAY;AACvB,UAAI;AACF,qBAAa,IAAI;AACjB,iBAAS,IAAI;AACb,cAAM,SAAS,MAAM,4BAA4B,MAAM;AACvD,YAAI,CAAC,QAAS;AAEd,cAAM,YAAY,OAAO,OAAO,CAAC,MAAM,OAAO,EAAE,WAAW,UAAU;AACrE,mBAAW,SAAS;AACpB,cAAM,WAAW,wBAAwB,SAAS;AAClD,cAAM,UAAU,MAAM,0BAA0B,QAAQ;AACxD,YAAI,CAAC,QAAS;AACd,2BAAmB,OAAO;AAAA,MAC5B,SAAS,WAAW;AAClB,YAAI,CAAC,QAAS;AACd,gBAAQ,MAAM,+DAA+D,MAAM,KAAK,SAAS;AACjG,iBAAS,qBAAqB,QAAQ,UAAU,UAAU,OAAO,SAAS,CAAC;AAC3E,mBAAW,CAAC,CAAC;AAAA,MACf,UAAE;AACA,YAAI,QAAS,cAAa,KAAK;AAAA,MACjC;AAAA,IACF;AACA,SAAK,KAAK;AACV,WAAO,MAAM;AACX,gBAAU;AAAA,IACZ;AAAA,EACF,GAAG,CAAC,MAAM,CAAC;AAEX,QAAM,qBAAqB,MAAM,QAAQ,MAAM,MAAM,KAAK,eAAe,GAAG,CAAC,eAAe,CAAC;AAE7F,QAAM,iBAAiB,MAAM;AAAA,IAC3B,MACE,QAAQ,OAAO,CAAC,WAAW;AACzB,YAAM,WAAW,OAAO,SAAS,YAAY,CAAC;AAC9C,aAAO,SAAS,WAAW,KAAK,eAAe,oBAAoB,QAAQ;AAAA,IAC7E,CAAC;AAAA,IACH,CAAC,SAAS,kBAAkB;AAAA,EAC9B;AAEA,SAAO,EAAE,SAAS,gBAAgB,WAAW,MAAM;AACrD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import { hasAllFeatures } from "@open-mercato/shared/security/features";
|
|
2
|
+
function isPortalPattern(pattern) {
|
|
3
|
+
if (!pattern) return false;
|
|
4
|
+
return pattern.startsWith("/[orgSlug]/portal/") || pattern === "/[orgSlug]/portal";
|
|
5
|
+
}
|
|
6
|
+
function hasNoUnresolvedParams(href) {
|
|
7
|
+
return !href.includes("[");
|
|
8
|
+
}
|
|
9
|
+
function resolveHref(pattern, orgSlug) {
|
|
10
|
+
return pattern.replace("[orgSlug]", orgSlug);
|
|
11
|
+
}
|
|
12
|
+
function pickGroup(group) {
|
|
13
|
+
if (group === "main" || group === "account") return group;
|
|
14
|
+
return "main";
|
|
15
|
+
}
|
|
16
|
+
function buildPortalNav({
|
|
17
|
+
routes,
|
|
18
|
+
orgSlug,
|
|
19
|
+
grantedFeatures,
|
|
20
|
+
isPortalAdmin = false
|
|
21
|
+
}) {
|
|
22
|
+
const mainItems = [];
|
|
23
|
+
const accountItems = [];
|
|
24
|
+
for (const route of routes) {
|
|
25
|
+
const pattern = route.pattern ?? route.path;
|
|
26
|
+
if (!isPortalPattern(pattern)) continue;
|
|
27
|
+
if (route.navHidden) continue;
|
|
28
|
+
const nav = route.nav;
|
|
29
|
+
if (!nav || typeof nav.label !== "string" || nav.label.length === 0) continue;
|
|
30
|
+
const requireFeatures = route.requireCustomerFeatures ?? [];
|
|
31
|
+
if (!isPortalAdmin && requireFeatures.length) {
|
|
32
|
+
if (!hasAllFeatures(grantedFeatures, requireFeatures)) continue;
|
|
33
|
+
}
|
|
34
|
+
const href = resolveHref(pattern, orgSlug);
|
|
35
|
+
if (!hasNoUnresolvedParams(href)) continue;
|
|
36
|
+
const group = pickGroup(nav.group);
|
|
37
|
+
const item = {
|
|
38
|
+
id: `portal-nav:${pattern}`,
|
|
39
|
+
label: nav.label,
|
|
40
|
+
labelKey: nav.labelKey,
|
|
41
|
+
href,
|
|
42
|
+
icon: nav.icon,
|
|
43
|
+
order: typeof nav.order === "number" ? nav.order : 100
|
|
44
|
+
};
|
|
45
|
+
if (group === "account") accountItems.push(item);
|
|
46
|
+
else mainItems.push(item);
|
|
47
|
+
}
|
|
48
|
+
const sortItems = (items) => items.sort((a, b) => {
|
|
49
|
+
if (a.order !== b.order) return a.order - b.order;
|
|
50
|
+
return a.label.localeCompare(b.label);
|
|
51
|
+
});
|
|
52
|
+
sortItems(mainItems);
|
|
53
|
+
sortItems(accountItems);
|
|
54
|
+
const groups = [];
|
|
55
|
+
if (mainItems.length) groups.push({ id: "main", items: mainItems });
|
|
56
|
+
if (accountItems.length) groups.push({ id: "account", items: accountItems });
|
|
57
|
+
return groups;
|
|
58
|
+
}
|
|
59
|
+
function mergePortalSidebarGroupsWithInjected(discovered, injected) {
|
|
60
|
+
const mergeGroup = (base, extra) => {
|
|
61
|
+
const knownIds = new Set(base.map((item) => item.id));
|
|
62
|
+
const knownHrefs = new Set(base.map((item) => item.href).filter((href) => Boolean(href)));
|
|
63
|
+
const merged = [...base];
|
|
64
|
+
for (const item of extra) {
|
|
65
|
+
if (knownIds.has(item.id)) continue;
|
|
66
|
+
if (item.href && knownHrefs.has(item.href)) continue;
|
|
67
|
+
merged.push(item);
|
|
68
|
+
knownIds.add(item.id);
|
|
69
|
+
if (item.href) knownHrefs.add(item.href);
|
|
70
|
+
}
|
|
71
|
+
return merged;
|
|
72
|
+
};
|
|
73
|
+
const mainBase = discovered.find((g) => g.id === "main")?.items ?? [];
|
|
74
|
+
const accountBase = discovered.find((g) => g.id === "account")?.items ?? [];
|
|
75
|
+
return {
|
|
76
|
+
main: mergeGroup(mainBase, injected.main),
|
|
77
|
+
account: mergeGroup(accountBase, injected.account)
|
|
78
|
+
};
|
|
79
|
+
}
|
|
80
|
+
export {
|
|
81
|
+
buildPortalNav,
|
|
82
|
+
mergePortalSidebarGroupsWithInjected
|
|
83
|
+
};
|
|
84
|
+
//# sourceMappingURL=nav.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/portal/utils/nav.ts"],
|
|
4
|
+
"sourcesContent": ["import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'\nimport { hasAllFeatures } from '@open-mercato/shared/security/features'\n\nexport type PortalNavGroupId = 'main' | 'account'\n\nexport type PortalNavItem = {\n id: string\n label: string\n labelKey?: string\n href: string\n icon?: string\n order: number\n}\n\nexport type PortalNavGroup = {\n id: PortalNavGroupId\n items: PortalNavItem[]\n}\n\nexport type BuildPortalNavOptions = {\n /** Route manifest to inspect (typically `getFrontendRouteManifests()`). */\n routes: readonly FrontendRouteManifestEntry[]\n /** Current customer org slug \u2014 substituted into `[orgSlug]` patterns. */\n orgSlug: string\n /** Feature strings granted to the current customer (may include wildcards). */\n grantedFeatures: readonly string[]\n /** If true, bypass feature checks (portal admin). Defaults to false. */\n isPortalAdmin?: boolean\n}\n\nfunction isPortalPattern(pattern: string | undefined): pattern is string {\n if (!pattern) return false\n return pattern.startsWith('/[orgSlug]/portal/') || pattern === '/[orgSlug]/portal'\n}\n\nfunction hasNoUnresolvedParams(href: string): boolean {\n return !href.includes('[')\n}\n\nfunction resolveHref(pattern: string, orgSlug: string): string {\n return pattern.replace('[orgSlug]', orgSlug)\n}\n\nfunction pickGroup(group: unknown): PortalNavGroupId {\n if (group === 'main' || group === 'account') return group\n return 'main'\n}\n\n/**\n * Build the portal sidebar from the frontend route manifest.\n *\n * Mirrors `buildAdminNav()` for the portal surface: selects routes under\n * `/[orgSlug]/portal/*` that declare a `nav` block, applies\n * `requireCustomerFeatures` against the caller's grants (wildcards honored),\n * and returns ordered sidebar groups.\n *\n * Absence of `nav` on a metadata file means the page is routable but not\n * auto-listed \u2014 useful for detail/create pages.\n */\nexport function buildPortalNav({\n routes,\n orgSlug,\n grantedFeatures,\n isPortalAdmin = false,\n}: BuildPortalNavOptions): PortalNavGroup[] {\n const mainItems: PortalNavItem[] = []\n const accountItems: PortalNavItem[] = []\n\n for (const route of routes) {\n const pattern = route.pattern ?? route.path\n if (!isPortalPattern(pattern)) continue\n if (route.navHidden) continue\n const nav = route.nav\n if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue\n\n const requireFeatures = route.requireCustomerFeatures ?? []\n if (!isPortalAdmin && requireFeatures.length) {\n if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue\n }\n\n const href = resolveHref(pattern as string, orgSlug)\n if (!hasNoUnresolvedParams(href)) continue\n\n const group = pickGroup(nav.group)\n const item: PortalNavItem = {\n id: `portal-nav:${pattern}`,\n label: nav.label,\n labelKey: nav.labelKey,\n href,\n icon: nav.icon,\n order: typeof nav.order === 'number' ? nav.order : 100,\n }\n if (group === 'account') accountItems.push(item)\n else mainItems.push(item)\n }\n\n const sortItems = (items: PortalNavItem[]) =>\n items.sort((a, b) => {\n if (a.order !== b.order) return a.order - b.order\n return a.label.localeCompare(b.label)\n })\n\n sortItems(mainItems)\n sortItems(accountItems)\n\n const groups: PortalNavGroup[] = []\n if (mainItems.length) groups.push({ id: 'main', items: mainItems })\n if (accountItems.length) groups.push({ id: 'account', items: accountItems })\n return groups\n}\n\n/**\n * Merge sidebar groups from the portal nav endpoint with items contributed via\n * `usePortalInjectedMenuItems`. Auto-discovered entries take precedence \u2014\n * injected items with matching `id` or `href` are dropped as duplicates.\n */\nexport function mergePortalSidebarGroupsWithInjected<TInjected extends { id: string; href?: string }>(\n discovered: readonly PortalNavGroup[],\n injected: {\n main: readonly TInjected[]\n account: readonly TInjected[]\n },\n): {\n main: Array<PortalNavItem | TInjected>\n account: Array<PortalNavItem | TInjected>\n} {\n const mergeGroup = <T extends PortalNavItem | TInjected>(\n base: readonly PortalNavItem[],\n extra: readonly TInjected[],\n ): Array<PortalNavItem | TInjected> => {\n const knownIds = new Set(base.map((item) => item.id))\n const knownHrefs = new Set(base.map((item) => item.href).filter((href): href is string => Boolean(href)))\n const merged: Array<PortalNavItem | TInjected> = [...base]\n for (const item of extra) {\n if (knownIds.has(item.id)) continue\n if (item.href && knownHrefs.has(item.href)) continue\n merged.push(item)\n knownIds.add(item.id)\n if (item.href) knownHrefs.add(item.href)\n }\n return merged\n }\n\n const mainBase = discovered.find((g) => g.id === 'main')?.items ?? []\n const accountBase = discovered.find((g) => g.id === 'account')?.items ?? []\n return {\n main: mergeGroup(mainBase, injected.main),\n account: mergeGroup(accountBase, injected.account),\n }\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.5.1-develop.
|
|
3
|
+
"version": "0.5.1-develop.2657.a01847a9fa",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -132,12 +132,12 @@
|
|
|
132
132
|
"recharts": "^3.8.1"
|
|
133
133
|
},
|
|
134
134
|
"peerDependencies": {
|
|
135
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
135
|
+
"@open-mercato/shared": "0.5.1-develop.2657.a01847a9fa",
|
|
136
136
|
"react": ">=18.0.0",
|
|
137
137
|
"react-dom": ">=18.0.0"
|
|
138
138
|
},
|
|
139
139
|
"devDependencies": {
|
|
140
|
-
"@open-mercato/shared": "0.5.1-develop.
|
|
140
|
+
"@open-mercato/shared": "0.5.1-develop.2657.a01847a9fa",
|
|
141
141
|
"@testing-library/dom": "^10.4.1",
|
|
142
142
|
"@testing-library/jest-dom": "^6.9.1",
|
|
143
143
|
"@testing-library/react": "^16.3.1",
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
"use client"
|
|
2
|
-
import { type ReactNode, useState, useCallback, useMemo, useContext } from 'react'
|
|
2
|
+
import { type ReactNode, useEffect, useState, useCallback, useMemo, useContext } from 'react'
|
|
3
3
|
import Image from 'next/image'
|
|
4
4
|
import Link from 'next/link'
|
|
5
5
|
import { usePathname } from 'next/navigation'
|
|
@@ -12,6 +12,8 @@ import { mergeMenuItems } from '../backend/injection/mergeMenuItems'
|
|
|
12
12
|
import type { MergedMenuItem } from '../backend/injection/mergeMenuItems'
|
|
13
13
|
import { PortalNotificationBell } from './components/PortalNotificationBell'
|
|
14
14
|
import { usePortalContext } from './PortalContext'
|
|
15
|
+
import { apiCall } from '../backend/utils/apiCall'
|
|
16
|
+
import type { PortalNavGroup } from './utils/nav'
|
|
15
17
|
|
|
16
18
|
// Component replacement handle IDs (FROZEN once shipped)
|
|
17
19
|
export const PORTAL_SHELL_HANDLE = 'page:portal:layout'
|
|
@@ -174,29 +176,59 @@ export function PortalShell({
|
|
|
174
176
|
const portalHome = orgSlug ? `/${orgSlug}/portal` : '/portal'
|
|
175
177
|
const loginHref = orgSlug ? `/${orgSlug}/portal/login` : '/portal/login'
|
|
176
178
|
const signupHref = orgSlug ? `/${orgSlug}/portal/signup` : '/portal/signup'
|
|
177
|
-
const dashboardHref = orgSlug ? `/${orgSlug}/portal/dashboard` : '/portal/dashboard'
|
|
178
|
-
const profileHref = orgSlug ? `/${orgSlug}/portal/profile` : '/portal/profile'
|
|
179
179
|
// Always use the resolved organization name from the database.
|
|
180
180
|
// Fall back to the generic portal title — never display the raw slug.
|
|
181
181
|
const headerTitle = orgName || t('portal.title', 'Customer Portal')
|
|
182
182
|
|
|
183
183
|
const closeMobile = useCallback(() => setMobileOpen(false), [])
|
|
184
184
|
|
|
185
|
+
const [autoNavGroups, setAutoNavGroups] = useState<PortalNavGroup[]>([])
|
|
186
|
+
useEffect(() => {
|
|
187
|
+
if (!authenticated) {
|
|
188
|
+
setAutoNavGroups([])
|
|
189
|
+
return
|
|
190
|
+
}
|
|
191
|
+
let cancelled = false
|
|
192
|
+
const load = async () => {
|
|
193
|
+
try {
|
|
194
|
+
const { ok, result } = await apiCall<{ ok: boolean; groups?: PortalNavGroup[] }>(
|
|
195
|
+
'/api/customer_accounts/portal/nav',
|
|
196
|
+
)
|
|
197
|
+
if (cancelled || !ok || !result?.ok) return
|
|
198
|
+
setAutoNavGroups(Array.isArray(result.groups) ? result.groups : [])
|
|
199
|
+
} catch {
|
|
200
|
+
if (!cancelled) setAutoNavGroups([])
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
void load()
|
|
204
|
+
return () => {
|
|
205
|
+
cancelled = true
|
|
206
|
+
}
|
|
207
|
+
}, [authenticated])
|
|
208
|
+
|
|
185
209
|
const mergedNavItems = useMemo(() => {
|
|
186
210
|
if (!authenticated) return []
|
|
187
|
-
const
|
|
188
|
-
|
|
189
|
-
|
|
211
|
+
const discovered = autoNavGroups.find((g) => g.id === 'main')?.items ?? []
|
|
212
|
+
const builtIn = discovered.map((item) => ({
|
|
213
|
+
id: item.id,
|
|
214
|
+
labelKey: item.labelKey,
|
|
215
|
+
label: item.label,
|
|
216
|
+
href: item.href,
|
|
217
|
+
}))
|
|
190
218
|
return mergeMenuItems(builtIn, injectedMainItems)
|
|
191
|
-
}, [authenticated,
|
|
219
|
+
}, [authenticated, autoNavGroups, injectedMainItems])
|
|
192
220
|
|
|
193
221
|
const mergedAccountItems = useMemo(() => {
|
|
194
222
|
if (!authenticated) return []
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
223
|
+
const discovered = autoNavGroups.find((g) => g.id === 'account')?.items ?? []
|
|
224
|
+
const builtIn = discovered.map((item) => ({
|
|
225
|
+
id: item.id,
|
|
226
|
+
labelKey: item.labelKey,
|
|
227
|
+
label: item.label,
|
|
228
|
+
href: item.href,
|
|
229
|
+
}))
|
|
198
230
|
return mergeMenuItems(builtIn, injectedAccountItems)
|
|
199
|
-
}, [authenticated,
|
|
231
|
+
}, [authenticated, autoNavGroups, injectedAccountItems])
|
|
200
232
|
|
|
201
233
|
/* ---- PUBLIC LAYOUT ---- */
|
|
202
234
|
if (!authenticated) {
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/** @jest-environment jsdom */
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { renderHook, waitFor } from '@testing-library/react'
|
|
4
|
+
|
|
5
|
+
const loadInjectionWidgetsForSpotMock = jest.fn()
|
|
6
|
+
const apiCallMock = jest.fn()
|
|
7
|
+
|
|
8
|
+
jest.mock('@open-mercato/shared/modules/widgets/injection-loader', () => ({
|
|
9
|
+
loadInjectionWidgetsForSpot: (...args: unknown[]) => loadInjectionWidgetsForSpotMock(...args),
|
|
10
|
+
}))
|
|
11
|
+
|
|
12
|
+
jest.mock('../../../backend/utils/apiCall', () => ({
|
|
13
|
+
apiCall: (...args: unknown[]) => apiCallMock(...args),
|
|
14
|
+
}))
|
|
15
|
+
|
|
16
|
+
import { usePortalDashboardWidgets } from '../usePortalDashboardWidgets'
|
|
17
|
+
|
|
18
|
+
function widget(id: string, features?: string[]) {
|
|
19
|
+
return {
|
|
20
|
+
moduleId: 'test',
|
|
21
|
+
spotId: 'portal:dashboard:sections',
|
|
22
|
+
widgetId: id,
|
|
23
|
+
Widget: () => null,
|
|
24
|
+
metadata: { id, features },
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function mockFeatureCheckGranted(granted: string[]) {
|
|
29
|
+
apiCallMock.mockImplementation(async (url: string) => {
|
|
30
|
+
if (url === '/api/customer_accounts/portal/feature-check') {
|
|
31
|
+
return { ok: true, result: { ok: true, granted } }
|
|
32
|
+
}
|
|
33
|
+
throw new Error(`unexpected apiCall: ${url}`)
|
|
34
|
+
})
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
describe('usePortalDashboardWidgets — feature gating (Phase 1 regression)', () => {
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.clearAllMocks()
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns widgets without required features regardless of grants', async () => {
|
|
43
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([widget('always-visible')])
|
|
44
|
+
// No features required → hook should skip the feature-check entirely
|
|
45
|
+
|
|
46
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
47
|
+
|
|
48
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
49
|
+
expect(result.current.widgets).toHaveLength(1)
|
|
50
|
+
expect(result.current.widgets[0].widgetId).toBe('always-visible')
|
|
51
|
+
expect(apiCallMock).not.toHaveBeenCalled()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('filters out widgets whose required feature the user lacks', async () => {
|
|
55
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
|
|
56
|
+
widget('visible', ['portal.orders.view']),
|
|
57
|
+
widget('hidden', ['portal.billing.manage']),
|
|
58
|
+
])
|
|
59
|
+
mockFeatureCheckGranted(['portal.orders.view'])
|
|
60
|
+
|
|
61
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
62
|
+
|
|
63
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
64
|
+
const ids = result.current.widgets.map((w) => w.widgetId)
|
|
65
|
+
expect(ids).toEqual(['visible'])
|
|
66
|
+
expect(apiCallMock).toHaveBeenCalledWith(
|
|
67
|
+
'/api/customer_accounts/portal/feature-check',
|
|
68
|
+
expect.objectContaining({ method: 'POST' }),
|
|
69
|
+
)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('resolves wildcard grants through the shared matcher', async () => {
|
|
73
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
|
|
74
|
+
widget('orders-view', ['portal.orders.view']),
|
|
75
|
+
widget('orders-create', ['portal.orders.create']),
|
|
76
|
+
widget('billing', ['portal.billing.manage']),
|
|
77
|
+
])
|
|
78
|
+
// Grant is a wildcard — server returns the concrete grants it matched.
|
|
79
|
+
mockFeatureCheckGranted(['portal.orders.view', 'portal.orders.create'])
|
|
80
|
+
|
|
81
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
82
|
+
|
|
83
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
84
|
+
const ids = result.current.widgets.map((w) => w.widgetId).sort()
|
|
85
|
+
expect(ids).toEqual(['orders-create', 'orders-view'])
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it('excludes all gated widgets when feature-check fails', async () => {
|
|
89
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([
|
|
90
|
+
widget('ungated'),
|
|
91
|
+
widget('gated', ['portal.orders.view']),
|
|
92
|
+
])
|
|
93
|
+
apiCallMock.mockRejectedValueOnce(new Error('network down'))
|
|
94
|
+
|
|
95
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
96
|
+
|
|
97
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
98
|
+
const ids = result.current.widgets.map((w) => w.widgetId)
|
|
99
|
+
// Ungated widget stays; gated widget is filtered because granted set is empty.
|
|
100
|
+
expect(ids).toEqual(['ungated'])
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
it('excludes widgets without a Widget component', async () => {
|
|
104
|
+
const noWidget = {
|
|
105
|
+
moduleId: 'test',
|
|
106
|
+
spotId: 'portal:dashboard:sections',
|
|
107
|
+
widgetId: 'data-only',
|
|
108
|
+
metadata: { id: 'data-only' },
|
|
109
|
+
} as any
|
|
110
|
+
loadInjectionWidgetsForSpotMock.mockResolvedValueOnce([widget('real'), noWidget])
|
|
111
|
+
|
|
112
|
+
const { result } = renderHook(() => usePortalDashboardWidgets('portal:dashboard:sections' as any))
|
|
113
|
+
|
|
114
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false))
|
|
115
|
+
expect(result.current.widgets.map((w) => w.widgetId)).toEqual(['real'])
|
|
116
|
+
})
|
|
117
|
+
})
|
|
@@ -3,6 +3,39 @@
|
|
|
3
3
|
import * as React from 'react'
|
|
4
4
|
import type { InjectionSpotId } from '@open-mercato/shared/modules/widgets/injection'
|
|
5
5
|
import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-mercato/shared/modules/widgets/injection-loader'
|
|
6
|
+
import { hasAllFeatures } from '@open-mercato/shared/security/features'
|
|
7
|
+
import { apiCall } from '../../backend/utils/apiCall'
|
|
8
|
+
|
|
9
|
+
type PortalFeatureCheckResponse = {
|
|
10
|
+
ok: boolean
|
|
11
|
+
granted?: string[]
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function collectRequiredFeatures(widgets: LoadedInjectionWidget[]): string[] {
|
|
15
|
+
const set = new Set<string>()
|
|
16
|
+
for (const widget of widgets) {
|
|
17
|
+
for (const feature of widget.metadata.features ?? []) {
|
|
18
|
+
if (!feature || feature.trim().length === 0) continue
|
|
19
|
+
set.add(feature)
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
return Array.from(set)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function readPortalGrantedFeatures(features: string[]): Promise<Set<string>> {
|
|
26
|
+
if (features.length === 0) return new Set()
|
|
27
|
+
try {
|
|
28
|
+
const { ok, result: data } = await apiCall<PortalFeatureCheckResponse>('/api/customer_accounts/portal/feature-check', {
|
|
29
|
+
method: 'POST',
|
|
30
|
+
headers: { 'content-type': 'application/json' },
|
|
31
|
+
body: JSON.stringify({ features }),
|
|
32
|
+
})
|
|
33
|
+
if (!ok || !data?.ok) return new Set()
|
|
34
|
+
return new Set(data.granted ?? [])
|
|
35
|
+
} catch {
|
|
36
|
+
return new Set()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
6
39
|
|
|
7
40
|
/**
|
|
8
41
|
* Loads UI injection widgets (with Widget component) for a portal spot.
|
|
@@ -10,6 +43,11 @@ import { loadInjectionWidgetsForSpot, type LoadedInjectionWidget } from '@open-m
|
|
|
10
43
|
* Unlike `useInjectionDataWidgets` which loads data-only widgets (columns, fields, menuItems),
|
|
11
44
|
* this hook loads widgets that export a `Widget` React component — suitable for
|
|
12
45
|
* portal dashboard sections and other UI injection spots.
|
|
46
|
+
*
|
|
47
|
+
* Feature gating: widgets declaring `metadata.features` are filtered against the
|
|
48
|
+
* authenticated customer's grants resolved via
|
|
49
|
+
* `/api/customer_accounts/portal/feature-check`. Wildcard grants (`portal.*`) resolve
|
|
50
|
+
* through the shared matcher.
|
|
13
51
|
*/
|
|
14
52
|
export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
15
53
|
widgets: LoadedInjectionWidget[]
|
|
@@ -19,6 +57,7 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
|
19
57
|
const [widgets, setWidgets] = React.useState<LoadedInjectionWidget[]>([])
|
|
20
58
|
const [isLoading, setIsLoading] = React.useState(true)
|
|
21
59
|
const [error, setError] = React.useState<string | null>(null)
|
|
60
|
+
const [grantedFeatures, setGrantedFeatures] = React.useState<Set<string>>(new Set())
|
|
22
61
|
|
|
23
62
|
React.useEffect(() => {
|
|
24
63
|
let mounted = true
|
|
@@ -31,6 +70,10 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
|
31
70
|
// Only keep widgets that have a Widget component
|
|
32
71
|
const uiWidgets = loaded.filter((w) => typeof w.Widget === 'function')
|
|
33
72
|
setWidgets(uiWidgets)
|
|
73
|
+
const required = collectRequiredFeatures(uiWidgets)
|
|
74
|
+
const granted = await readPortalGrantedFeatures(required)
|
|
75
|
+
if (!mounted) return
|
|
76
|
+
setGrantedFeatures(granted)
|
|
34
77
|
} catch (loadError) {
|
|
35
78
|
if (!mounted) return
|
|
36
79
|
console.error(`[usePortalDashboardWidgets] Failed to load widgets for spot ${spotId}:`, loadError)
|
|
@@ -46,5 +89,16 @@ export function usePortalDashboardWidgets(spotId: InjectionSpotId): {
|
|
|
46
89
|
}
|
|
47
90
|
}, [spotId])
|
|
48
91
|
|
|
49
|
-
|
|
92
|
+
const grantedFeatureList = React.useMemo(() => Array.from(grantedFeatures), [grantedFeatures])
|
|
93
|
+
|
|
94
|
+
const visibleWidgets = React.useMemo(
|
|
95
|
+
() =>
|
|
96
|
+
widgets.filter((widget) => {
|
|
97
|
+
const required = widget.metadata.features ?? []
|
|
98
|
+
return required.length === 0 || hasAllFeatures(grantedFeatureList, required)
|
|
99
|
+
}),
|
|
100
|
+
[widgets, grantedFeatureList],
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
return { widgets: visibleWidgets, isLoading, error }
|
|
50
104
|
}
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
import { buildPortalNav, mergePortalSidebarGroupsWithInjected } from '../nav'
|
|
3
|
+
|
|
4
|
+
function makeRoute(partial: Partial<FrontendRouteManifestEntry>): FrontendRouteManifestEntry {
|
|
5
|
+
return {
|
|
6
|
+
moduleId: 'test',
|
|
7
|
+
pattern: '/[orgSlug]/portal/test',
|
|
8
|
+
load: async () => null as any,
|
|
9
|
+
...partial,
|
|
10
|
+
} as FrontendRouteManifestEntry
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
describe('buildPortalNav', () => {
|
|
14
|
+
it('auto-lists portal pages that declare nav metadata', () => {
|
|
15
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
16
|
+
makeRoute({
|
|
17
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
18
|
+
nav: { label: 'Dashboard', labelKey: 'portal.nav.dashboard', group: 'main', order: 10 },
|
|
19
|
+
}),
|
|
20
|
+
makeRoute({
|
|
21
|
+
pattern: '/[orgSlug]/portal/profile',
|
|
22
|
+
nav: { label: 'Profile', labelKey: 'portal.nav.profile', group: 'account', order: 10 },
|
|
23
|
+
}),
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
27
|
+
|
|
28
|
+
expect(groups).toEqual([
|
|
29
|
+
{
|
|
30
|
+
id: 'main',
|
|
31
|
+
items: [
|
|
32
|
+
expect.objectContaining({
|
|
33
|
+
label: 'Dashboard',
|
|
34
|
+
labelKey: 'portal.nav.dashboard',
|
|
35
|
+
href: '/my-org/portal/dashboard',
|
|
36
|
+
order: 10,
|
|
37
|
+
}),
|
|
38
|
+
],
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: 'account',
|
|
42
|
+
items: [
|
|
43
|
+
expect.objectContaining({
|
|
44
|
+
label: 'Profile',
|
|
45
|
+
labelKey: 'portal.nav.profile',
|
|
46
|
+
href: '/my-org/portal/profile',
|
|
47
|
+
order: 10,
|
|
48
|
+
}),
|
|
49
|
+
],
|
|
50
|
+
},
|
|
51
|
+
])
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it('skips pages without nav metadata', () => {
|
|
55
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
56
|
+
makeRoute({ pattern: '/[orgSlug]/portal/login' }),
|
|
57
|
+
makeRoute({
|
|
58
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
59
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
60
|
+
}),
|
|
61
|
+
]
|
|
62
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
63
|
+
expect(groups).toHaveLength(1)
|
|
64
|
+
expect(groups[0].id).toBe('main')
|
|
65
|
+
expect(groups[0].items.map((i) => i.label)).toEqual(['Dashboard'])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('skips pages the user lacks required features for', () => {
|
|
69
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
70
|
+
makeRoute({
|
|
71
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
72
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
73
|
+
nav: { label: 'Orders', group: 'main' },
|
|
74
|
+
}),
|
|
75
|
+
makeRoute({
|
|
76
|
+
pattern: '/[orgSlug]/portal/dashboard',
|
|
77
|
+
nav: { label: 'Dashboard', group: 'main' },
|
|
78
|
+
}),
|
|
79
|
+
]
|
|
80
|
+
|
|
81
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })
|
|
82
|
+
expect(groups).toEqual([
|
|
83
|
+
{ id: 'main', items: [expect.objectContaining({ label: 'Dashboard' })] },
|
|
84
|
+
])
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('matches wildcard grants like portal.*', () => {
|
|
88
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
89
|
+
makeRoute({
|
|
90
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
91
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
92
|
+
nav: { label: 'Orders', group: 'main' },
|
|
93
|
+
}),
|
|
94
|
+
]
|
|
95
|
+
|
|
96
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: ['portal.*'] })
|
|
97
|
+
expect(groups).toEqual([{ id: 'main', items: [expect.objectContaining({ label: 'Orders' })] }])
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('bypasses feature checks when isPortalAdmin is true', () => {
|
|
101
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
102
|
+
makeRoute({
|
|
103
|
+
pattern: '/[orgSlug]/portal/orders',
|
|
104
|
+
requireCustomerFeatures: ['portal.orders.view'],
|
|
105
|
+
nav: { label: 'Orders', group: 'main' },
|
|
106
|
+
}),
|
|
107
|
+
]
|
|
108
|
+
|
|
109
|
+
const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [], isPortalAdmin: true })
|
|
110
|
+
expect(groups[0].items[0].label).toBe('Orders')
|
|
111
|
+
})
|
|
112
|
+
|
|
113
|
+
it('ignores navHidden pages even when nav is declared', () => {
|
|
114
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
115
|
+
makeRoute({
|
|
116
|
+
pattern: '/[orgSlug]/portal/secret',
|
|
117
|
+
navHidden: true,
|
|
118
|
+
nav: { label: 'Secret', group: 'main' },
|
|
119
|
+
}),
|
|
120
|
+
]
|
|
121
|
+
expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
it('skips non-portal routes and dynamic patterns with unresolved params', () => {
|
|
125
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
126
|
+
makeRoute({
|
|
127
|
+
pattern: '/[orgSlug]/portal/orders/[id]',
|
|
128
|
+
nav: { label: 'Order Detail', group: 'main' },
|
|
129
|
+
}),
|
|
130
|
+
makeRoute({
|
|
131
|
+
pattern: '/[orgSlug]/checkout',
|
|
132
|
+
nav: { label: 'Checkout', group: 'main' },
|
|
133
|
+
}),
|
|
134
|
+
]
|
|
135
|
+
expect(buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })).toEqual([])
|
|
136
|
+
})
|
|
137
|
+
|
|
138
|
+
it('sorts items by order then label', () => {
|
|
139
|
+
const routes: FrontendRouteManifestEntry[] = [
|
|
140
|
+
makeRoute({
|
|
141
|
+
pattern: '/[orgSlug]/portal/b',
|
|
142
|
+
nav: { label: 'B', group: 'main', order: 20 },
|
|
143
|
+
}),
|
|
144
|
+
makeRoute({
|
|
145
|
+
pattern: '/[orgSlug]/portal/a',
|
|
146
|
+
nav: { label: 'A', group: 'main', order: 10 },
|
|
147
|
+
}),
|
|
148
|
+
makeRoute({
|
|
149
|
+
pattern: '/[orgSlug]/portal/aa',
|
|
150
|
+
nav: { label: 'Aa', group: 'main', order: 10 },
|
|
151
|
+
}),
|
|
152
|
+
]
|
|
153
|
+
const main = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [] })[0]
|
|
154
|
+
expect(main.items.map((i) => i.label)).toEqual(['A', 'Aa', 'B'])
|
|
155
|
+
})
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
describe('mergePortalSidebarGroupsWithInjected', () => {
|
|
159
|
+
it('dedupes injected items by id', () => {
|
|
160
|
+
const result = mergePortalSidebarGroupsWithInjected(
|
|
161
|
+
[
|
|
162
|
+
{
|
|
163
|
+
id: 'main',
|
|
164
|
+
items: [
|
|
165
|
+
{ id: 'portal-nav:/[orgSlug]/portal/dashboard', label: 'Dashboard', href: '/x/portal/dashboard', order: 10 },
|
|
166
|
+
],
|
|
167
|
+
},
|
|
168
|
+
],
|
|
169
|
+
{
|
|
170
|
+
main: [
|
|
171
|
+
{ id: 'portal-nav:/[orgSlug]/portal/dashboard', label: 'Dashboard (injected)', href: '/x/portal/dashboard' } as any,
|
|
172
|
+
{ id: 'orders-external', label: 'External', href: 'https://external' } as any,
|
|
173
|
+
],
|
|
174
|
+
account: [],
|
|
175
|
+
},
|
|
176
|
+
)
|
|
177
|
+
expect(result.main).toHaveLength(2)
|
|
178
|
+
expect(result.main[0]).toEqual(expect.objectContaining({ label: 'Dashboard' }))
|
|
179
|
+
expect(result.main[1]).toEqual(expect.objectContaining({ id: 'orders-external' }))
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
it('dedupes injected items by href', () => {
|
|
183
|
+
const result = mergePortalSidebarGroupsWithInjected(
|
|
184
|
+
[
|
|
185
|
+
{
|
|
186
|
+
id: 'main',
|
|
187
|
+
items: [
|
|
188
|
+
{ id: 'portal-nav:/[orgSlug]/portal/profile', label: 'Profile', href: '/x/portal/profile', order: 10 },
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
{
|
|
193
|
+
main: [{ id: 'different-id', label: 'Profile', href: '/x/portal/profile' } as any],
|
|
194
|
+
account: [],
|
|
195
|
+
},
|
|
196
|
+
)
|
|
197
|
+
expect(result.main).toHaveLength(1)
|
|
198
|
+
})
|
|
199
|
+
})
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import type { FrontendRouteManifestEntry } from '@open-mercato/shared/modules/registry'
|
|
2
|
+
import { hasAllFeatures } from '@open-mercato/shared/security/features'
|
|
3
|
+
|
|
4
|
+
export type PortalNavGroupId = 'main' | 'account'
|
|
5
|
+
|
|
6
|
+
export type PortalNavItem = {
|
|
7
|
+
id: string
|
|
8
|
+
label: string
|
|
9
|
+
labelKey?: string
|
|
10
|
+
href: string
|
|
11
|
+
icon?: string
|
|
12
|
+
order: number
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export type PortalNavGroup = {
|
|
16
|
+
id: PortalNavGroupId
|
|
17
|
+
items: PortalNavItem[]
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type BuildPortalNavOptions = {
|
|
21
|
+
/** Route manifest to inspect (typically `getFrontendRouteManifests()`). */
|
|
22
|
+
routes: readonly FrontendRouteManifestEntry[]
|
|
23
|
+
/** Current customer org slug — substituted into `[orgSlug]` patterns. */
|
|
24
|
+
orgSlug: string
|
|
25
|
+
/** Feature strings granted to the current customer (may include wildcards). */
|
|
26
|
+
grantedFeatures: readonly string[]
|
|
27
|
+
/** If true, bypass feature checks (portal admin). Defaults to false. */
|
|
28
|
+
isPortalAdmin?: boolean
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPortalPattern(pattern: string | undefined): pattern is string {
|
|
32
|
+
if (!pattern) return false
|
|
33
|
+
return pattern.startsWith('/[orgSlug]/portal/') || pattern === '/[orgSlug]/portal'
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function hasNoUnresolvedParams(href: string): boolean {
|
|
37
|
+
return !href.includes('[')
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function resolveHref(pattern: string, orgSlug: string): string {
|
|
41
|
+
return pattern.replace('[orgSlug]', orgSlug)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function pickGroup(group: unknown): PortalNavGroupId {
|
|
45
|
+
if (group === 'main' || group === 'account') return group
|
|
46
|
+
return 'main'
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Build the portal sidebar from the frontend route manifest.
|
|
51
|
+
*
|
|
52
|
+
* Mirrors `buildAdminNav()` for the portal surface: selects routes under
|
|
53
|
+
* `/[orgSlug]/portal/*` that declare a `nav` block, applies
|
|
54
|
+
* `requireCustomerFeatures` against the caller's grants (wildcards honored),
|
|
55
|
+
* and returns ordered sidebar groups.
|
|
56
|
+
*
|
|
57
|
+
* Absence of `nav` on a metadata file means the page is routable but not
|
|
58
|
+
* auto-listed — useful for detail/create pages.
|
|
59
|
+
*/
|
|
60
|
+
export function buildPortalNav({
|
|
61
|
+
routes,
|
|
62
|
+
orgSlug,
|
|
63
|
+
grantedFeatures,
|
|
64
|
+
isPortalAdmin = false,
|
|
65
|
+
}: BuildPortalNavOptions): PortalNavGroup[] {
|
|
66
|
+
const mainItems: PortalNavItem[] = []
|
|
67
|
+
const accountItems: PortalNavItem[] = []
|
|
68
|
+
|
|
69
|
+
for (const route of routes) {
|
|
70
|
+
const pattern = route.pattern ?? route.path
|
|
71
|
+
if (!isPortalPattern(pattern)) continue
|
|
72
|
+
if (route.navHidden) continue
|
|
73
|
+
const nav = route.nav
|
|
74
|
+
if (!nav || typeof nav.label !== 'string' || nav.label.length === 0) continue
|
|
75
|
+
|
|
76
|
+
const requireFeatures = route.requireCustomerFeatures ?? []
|
|
77
|
+
if (!isPortalAdmin && requireFeatures.length) {
|
|
78
|
+
if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const href = resolveHref(pattern as string, orgSlug)
|
|
82
|
+
if (!hasNoUnresolvedParams(href)) continue
|
|
83
|
+
|
|
84
|
+
const group = pickGroup(nav.group)
|
|
85
|
+
const item: PortalNavItem = {
|
|
86
|
+
id: `portal-nav:${pattern}`,
|
|
87
|
+
label: nav.label,
|
|
88
|
+
labelKey: nav.labelKey,
|
|
89
|
+
href,
|
|
90
|
+
icon: nav.icon,
|
|
91
|
+
order: typeof nav.order === 'number' ? nav.order : 100,
|
|
92
|
+
}
|
|
93
|
+
if (group === 'account') accountItems.push(item)
|
|
94
|
+
else mainItems.push(item)
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const sortItems = (items: PortalNavItem[]) =>
|
|
98
|
+
items.sort((a, b) => {
|
|
99
|
+
if (a.order !== b.order) return a.order - b.order
|
|
100
|
+
return a.label.localeCompare(b.label)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
sortItems(mainItems)
|
|
104
|
+
sortItems(accountItems)
|
|
105
|
+
|
|
106
|
+
const groups: PortalNavGroup[] = []
|
|
107
|
+
if (mainItems.length) groups.push({ id: 'main', items: mainItems })
|
|
108
|
+
if (accountItems.length) groups.push({ id: 'account', items: accountItems })
|
|
109
|
+
return groups
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Merge sidebar groups from the portal nav endpoint with items contributed via
|
|
114
|
+
* `usePortalInjectedMenuItems`. Auto-discovered entries take precedence —
|
|
115
|
+
* injected items with matching `id` or `href` are dropped as duplicates.
|
|
116
|
+
*/
|
|
117
|
+
export function mergePortalSidebarGroupsWithInjected<TInjected extends { id: string; href?: string }>(
|
|
118
|
+
discovered: readonly PortalNavGroup[],
|
|
119
|
+
injected: {
|
|
120
|
+
main: readonly TInjected[]
|
|
121
|
+
account: readonly TInjected[]
|
|
122
|
+
},
|
|
123
|
+
): {
|
|
124
|
+
main: Array<PortalNavItem | TInjected>
|
|
125
|
+
account: Array<PortalNavItem | TInjected>
|
|
126
|
+
} {
|
|
127
|
+
const mergeGroup = <T extends PortalNavItem | TInjected>(
|
|
128
|
+
base: readonly PortalNavItem[],
|
|
129
|
+
extra: readonly TInjected[],
|
|
130
|
+
): Array<PortalNavItem | TInjected> => {
|
|
131
|
+
const knownIds = new Set(base.map((item) => item.id))
|
|
132
|
+
const knownHrefs = new Set(base.map((item) => item.href).filter((href): href is string => Boolean(href)))
|
|
133
|
+
const merged: Array<PortalNavItem | TInjected> = [...base]
|
|
134
|
+
for (const item of extra) {
|
|
135
|
+
if (knownIds.has(item.id)) continue
|
|
136
|
+
if (item.href && knownHrefs.has(item.href)) continue
|
|
137
|
+
merged.push(item)
|
|
138
|
+
knownIds.add(item.id)
|
|
139
|
+
if (item.href) knownHrefs.add(item.href)
|
|
140
|
+
}
|
|
141
|
+
return merged
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const mainBase = discovered.find((g) => g.id === 'main')?.items ?? []
|
|
145
|
+
const accountBase = discovered.find((g) => g.id === 'account')?.items ?? []
|
|
146
|
+
return {
|
|
147
|
+
main: mergeGroup(mainBase, injected.main),
|
|
148
|
+
account: mergeGroup(accountBase, injected.account),
|
|
149
|
+
}
|
|
150
|
+
}
|