@open-mercato/ui 0.6.7-develop.6775.1.c2313bb8a3 → 0.6.7-develop.6785.1.1dd7cfac55

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.
@@ -26,8 +26,7 @@ function pickPreferredRoute(existing, candidate) {
26
26
  function buildPortalNav({
27
27
  routes,
28
28
  orgSlug,
29
- grantedFeatures,
30
- isPortalAdmin = false
29
+ grantedFeatures
31
30
  }) {
32
31
  const mainItems = [];
33
32
  const accountItems = [];
@@ -45,7 +44,7 @@ function buildPortalNav({
45
44
  const pattern = route.pattern ?? route.path;
46
45
  const nav = route.nav;
47
46
  const requireFeatures = route.requireCustomerFeatures ?? [];
48
- if (!isPortalAdmin && requireFeatures.length) {
47
+ if (requireFeatures.length) {
49
48
  if (!hasAllFeatures(grantedFeatures, requireFeatures)) continue;
50
49
  }
51
50
  const href = resolveHref(pattern, orgSlug);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "version": 3,
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\nfunction pickPreferredRoute(\n existing: FrontendRouteManifestEntry,\n candidate: FrontendRouteManifestEntry,\n): FrontendRouteManifestEntry {\n const existingFeatures = existing.requireCustomerFeatures ?? []\n const candidateFeatures = candidate.requireCustomerFeatures ?? []\n if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate\n if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing\n if (existingFeatures.length !== candidateFeatures.length) {\n return candidateFeatures.length > existingFeatures.length ? candidate : existing\n }\n return existing\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 const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()\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 const existing = dedupedByPattern.get(pattern)\n dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)\n }\n\n for (const route of dedupedByPattern.values()) {\n const pattern = (route.pattern ?? route.path) as string\n const nav = route.nav!\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, 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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",
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 /** Concrete effective features granted to the current customer. */\n grantedFeatures: readonly string[]\n /** @deprecated Portal admins must receive concrete effective features. */\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\nfunction pickPreferredRoute(\n existing: FrontendRouteManifestEntry,\n candidate: FrontendRouteManifestEntry,\n): FrontendRouteManifestEntry {\n const existingFeatures = existing.requireCustomerFeatures ?? []\n const candidateFeatures = candidate.requireCustomerFeatures ?? []\n if (existingFeatures.length === 0 && candidateFeatures.length > 0) return candidate\n if (candidateFeatures.length === 0 && existingFeatures.length > 0) return existing\n if (existingFeatures.length !== candidateFeatures.length) {\n return candidateFeatures.length > existingFeatures.length ? candidate : existing\n }\n return existing\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}: BuildPortalNavOptions): PortalNavGroup[] {\n const mainItems: PortalNavItem[] = []\n const accountItems: PortalNavItem[] = []\n\n const dedupedByPattern = new Map<string, FrontendRouteManifestEntry>()\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 const existing = dedupedByPattern.get(pattern)\n dedupedByPattern.set(pattern, existing ? pickPreferredRoute(existing, route) : route)\n }\n\n for (const route of dedupedByPattern.values()) {\n const pattern = (route.pattern ?? route.path) as string\n const nav = route.nav!\n\n const requireFeatures = route.requireCustomerFeatures ?? []\n if (requireFeatures.length) {\n if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue\n }\n\n const href = resolveHref(pattern, 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
6
  "names": []
7
7
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@open-mercato/ui",
3
- "version": "0.6.7-develop.6775.1.c2313bb8a3",
3
+ "version": "0.6.7-develop.6785.1.1dd7cfac55",
4
4
  "license": "MIT",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -155,13 +155,13 @@
155
155
  "remark-gfm": "^4.0.1"
156
156
  },
157
157
  "peerDependencies": {
158
- "@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
158
+ "@open-mercato/shared": "0.6.7-develop.6785.1.1dd7cfac55",
159
159
  "react": ">=18.0.0",
160
160
  "react-dom": ">=18.0.0",
161
161
  "react-is": ">=18.0.0"
162
162
  },
163
163
  "devDependencies": {
164
- "@open-mercato/shared": "0.6.7-develop.6775.1.c2313bb8a3",
164
+ "@open-mercato/shared": "0.6.7-develop.6785.1.1dd7cfac55",
165
165
  "@testing-library/dom": "^10.4.1",
166
166
  "@testing-library/jest-dom": "^6.9.1",
167
167
  "@testing-library/react": "^16.3.1",
package/src/ai/AiChat.tsx CHANGED
@@ -304,10 +304,39 @@ function mapErrorCodeToVariant(
304
304
  'tool_not_whitelisted',
305
305
  'tool_features_denied',
306
306
  'attachment_type_not_accepted',
307
+ // Content-safety rejections are soft: the user can rephrase and retry.
308
+ 'moderation_blocked',
309
+ 'moderation_unavailable',
307
310
  ])
308
311
  return warningCodes.has(code) ? 'warning' : 'destructive'
309
312
  }
310
313
 
314
+ /**
315
+ * Maps known error codes to a translated, user-safe message. For content-safety
316
+ * codes the raw server message is intentionally generic (no category oracle);
317
+ * the UI shows the friendly localized copy instead of the internal text.
318
+ */
319
+ function resolveChatErrorMessage(
320
+ code: string | undefined,
321
+ fallback: string,
322
+ translate: (key: string, fallbackText?: string) => string,
323
+ ): string {
324
+ switch (code) {
325
+ case 'moderation_blocked':
326
+ return translate(
327
+ 'ai_assistant.errors.moderationBlocked',
328
+ 'Your message was blocked by the content safety filter. Please rephrase and try again.',
329
+ )
330
+ case 'moderation_unavailable':
331
+ return translate(
332
+ 'ai_assistant.errors.moderationUnavailable',
333
+ 'The content safety check is temporarily unavailable. Please try again in a moment.',
334
+ )
335
+ default:
336
+ return fallback
337
+ }
338
+ }
339
+
311
340
  const MARKDOWN_TYPOGRAPHY_CLASS = cn(
312
341
  '[&_p]:my-1 [&_p:first-child]:mt-0 [&_p:last-child]:mb-0',
313
342
  '[&_ul]:my-2 [&_ol]:my-2 [&_ul]:ml-4 [&_ol]:ml-4 [&_ul]:list-disc [&_ol]:list-decimal',
@@ -1630,7 +1659,7 @@ export function AiChat({
1630
1659
  {chat.error.code ? (
1631
1660
  <span className="mr-2 font-mono text-xs">{chat.error.code}</span>
1632
1661
  ) : null}
1633
- {chat.error.message}
1662
+ {resolveChatErrorMessage(chat.error.code, chat.error.message, t)}
1634
1663
  </AlertDescription>
1635
1664
  </Alert>
1636
1665
  ) : null}
@@ -93,6 +93,8 @@ const dict = {
93
93
  'ai_assistant.chat.emptyTranscript':
94
94
  'No messages yet. Ask the agent anything to get started.',
95
95
  'ai_assistant.chat.errorTitle': 'Agent dispatch failed',
96
+ 'ai_assistant.errors.moderationBlocked':
97
+ 'Your message was blocked by the content safety filter. Please rephrase and try again.',
96
98
  'ai_assistant.chat.agentTasksTitle': 'Tool calls',
97
99
  'ai_assistant.chat.regionLabel': 'AI chat',
98
100
  'ai_assistant.chat.send': 'Send message',
@@ -577,6 +579,35 @@ describe('<AiChat>', () => {
577
579
  expect(screen.getByText('agent_unknown')).toBeInTheDocument()
578
580
  })
579
581
 
582
+ it('renders the translated message (not the raw server text) for a moderation_blocked envelope', async () => {
583
+ const fetchMock = apiFetch as unknown as jest.Mock
584
+ fetchMock.mockResolvedValueOnce(
585
+ createErrorResponse(400, {
586
+ error: 'Input rejected by the content safety filter.',
587
+ code: 'moderation_blocked',
588
+ }),
589
+ )
590
+
591
+ renderWithProviders(<AiChat agent="customers.account_assistant" />, { dict })
592
+
593
+ const textarea = screen.getByLabelText('Message composer') as HTMLTextAreaElement
594
+ fireEvent.change(textarea, { target: { value: 'flagged input' } })
595
+ await act(async () => {
596
+ fireEvent.keyDown(textarea, { key: 'Enter', ctrlKey: true })
597
+ })
598
+
599
+ await waitFor(() => {
600
+ expect(screen.getByText('moderation_blocked')).toBeInTheDocument()
601
+ })
602
+ // The friendly localized copy is shown; the internal server text is not.
603
+ expect(
604
+ screen.getByText(/blocked by the content safety filter/i),
605
+ ).toBeInTheDocument()
606
+ expect(screen.queryByText('Input rejected by the content safety filter.')).toBeNull()
607
+ // Content-safety rejections render as a soft warning, not destructive.
608
+ expect(document.querySelector('[data-ai-chat-error="moderation_blocked"]')).not.toBeNull()
609
+ })
610
+
580
611
  it('Escape aborts an in-flight streaming response', async () => {
581
612
  const fetchMock = apiFetch as unknown as jest.Mock
582
613
  // Build a stream we can keep open until the component aborts it.
@@ -87,6 +87,7 @@ function isBooleanRecord(value: unknown): value is Record<string, boolean> {
87
87
  export type ShellLogo = {
88
88
  src: string
89
89
  alt?: string
90
+ preserveAspectRatio?: boolean
90
91
  }
91
92
 
92
93
  export type AppShellProps = {
@@ -186,6 +187,56 @@ function shouldBypassLogoOptimization(src?: string | null): boolean {
186
187
  return /^https?:\/\//.test(value) || /^\/api\/attachments\/(?:image|file)\//.test(value)
187
188
  }
188
189
 
190
+ function ShellBrandLogo({
191
+ logo,
192
+ brandName,
193
+ unoptimized,
194
+ compact = false,
195
+ mobile = false,
196
+ }: {
197
+ logo?: ShellLogo
198
+ brandName: string
199
+ unoptimized?: boolean
200
+ compact?: boolean
201
+ mobile?: boolean
202
+ }) {
203
+ const src = logo?.src ?? '/open-mercato.svg'
204
+ const alt = logo?.alt ?? brandName
205
+ const isCustomLogo = Boolean(logo?.src)
206
+ const preserveAspectRatio = Boolean(logo?.preserveAspectRatio)
207
+ if (!isCustomLogo || !preserveAspectRatio) {
208
+ return (
209
+ <Image
210
+ src={src}
211
+ alt={alt}
212
+ width={mobile ? 28 : 40}
213
+ height={mobile ? 28 : 40}
214
+ className={`${mobile ? 'rounded' : 'rounded-full'} shrink-0 object-cover`}
215
+ unoptimized={unoptimized ? true : undefined}
216
+ />
217
+ )
218
+ }
219
+
220
+ const width = compact ? 40 : mobile ? 96 : 120
221
+ const height = mobile ? 28 : 40
222
+ const className = compact
223
+ ? 'h-10 max-w-10 w-auto shrink-0 object-contain'
224
+ : mobile
225
+ ? 'h-7 max-w-24 w-auto shrink-0 object-contain'
226
+ : 'h-10 max-w-[120px] w-auto shrink-0 object-contain'
227
+
228
+ return (
229
+ <Image
230
+ src={src}
231
+ alt={alt}
232
+ width={width}
233
+ height={height}
234
+ className={className}
235
+ unoptimized={unoptimized ? true : undefined}
236
+ />
237
+ )
238
+ }
239
+
189
240
  function mergeSidebarItemsWithInjected(
190
241
  items: SidebarItem[],
191
242
  injectedItems: InjectionMenuItem[],
@@ -760,7 +811,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
760
811
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
761
812
  aria-label={t('appShell.goToDashboard')}
762
813
  >
763
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
814
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
764
815
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
765
816
  </Link>
766
817
  </div>
@@ -893,7 +944,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
893
944
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
894
945
  aria-label={t('appShell.goToDashboard')}
895
946
  >
896
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
947
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
897
948
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
898
949
  </Link>
899
950
  </div>
@@ -959,7 +1010,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
959
1010
  className={`flex items-center gap-3 rounded-xl transition-colors hover:bg-muted ${compact ? 'p-2 justify-center' : 'p-3'}`}
960
1011
  aria-label={t('appShell.goToDashboard')}
961
1012
  >
962
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={40} height={40} className="rounded-full shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1013
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} compact={compact} unoptimized={resolvedLogoBypassesOptimization} />
963
1014
  {!compact && <span className="truncate text-sm font-medium text-foreground">{resolvedBrandName}</span>}
964
1015
  </Link>
965
1016
  </div>
@@ -1458,7 +1509,7 @@ function AppShellBody({ productName, logo, email, canManageUpgradeActions = fals
1458
1509
  <aside className="absolute left-0 top-0 flex h-full w-[280px] max-w-[85vw] flex-col bg-background border-r shadow-lg overflow-hidden">
1459
1510
  <div className="shrink-0 flex items-center justify-between gap-2 border-b px-4 py-3">
1460
1511
  <Link href="/backend" className="flex items-center gap-2 min-w-0 text-sm font-semibold" onClick={() => setMobileOpen(false)} aria-label={t('appShell.goToDashboard')}>
1461
- <Image src={resolvedLogo?.src ?? "/open-mercato.svg"} alt={resolvedLogo?.alt ?? resolvedBrandName} width={28} height={28} className="rounded shrink-0" unoptimized={resolvedLogoBypassesOptimization ? true : undefined} />
1512
+ <ShellBrandLogo logo={resolvedLogo} brandName={resolvedBrandName} mobile unoptimized={resolvedLogoBypassesOptimization} />
1462
1513
  <span className="truncate">{resolvedBrandName}</span>
1463
1514
  </Link>
1464
1515
  <IconButton variant="ghost" size="sm" onClick={() => setMobileOpen(false)} aria-label={t('appShell.closeMenu')}>
@@ -226,7 +226,11 @@ describe('AppShell', () => {
226
226
  expect(screen.getByTestId('injection-spot:backend:layout:footer')).toBeInTheDocument()
227
227
  })
228
228
 
229
- it('uses backend chrome brand logo when the selected organization has one', async () => {
229
+ it.each([
230
+ ['internal-file', '/api/attachments/file/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'],
231
+ ['internal-image-query', '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.svg?width=320&height=320'],
232
+ ['external-webp', 'https://example.com/acme-wide-logo.webp'],
233
+ ])('uses an aspect-ratio-preserving backend chrome brand logo when enabled for %s', async (variant, logoSrc) => {
230
234
  const previousFetch = global.fetch
231
235
  const previousWindowFetch = window.fetch
232
236
  const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
@@ -235,8 +239,9 @@ describe('AppShell', () => {
235
239
  brand: {
236
240
  name: 'Acme',
237
241
  logo: {
238
- src: '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
242
+ src: logoSrc,
239
243
  alt: 'Acme logo',
244
+ preserveAspectRatio: true,
240
245
  },
241
246
  },
242
247
  groups,
@@ -257,7 +262,7 @@ describe('AppShell', () => {
257
262
  <AppShell
258
263
  email="demo@example.com"
259
264
  groups={[]}
260
- adminNavApi="/api/auth/admin/nav-brand-logo"
265
+ adminNavApi={`/api/auth/admin/nav-brand-logo-${variant}`}
261
266
  >
262
267
  <div>Child content</div>
263
268
  </AppShell>,
@@ -266,11 +271,10 @@ describe('AppShell', () => {
266
271
 
267
272
  await waitFor(() => {
268
273
  const logo = screen.getByAltText('Acme logo')
269
- expect(logo).toHaveAttribute(
270
- 'src',
271
- '/api/attachments/image/aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa/acme.png?width=320',
272
- )
274
+ expect(logo).toHaveAttribute('src', logoSrc)
273
275
  expect(logo).toHaveAttribute('data-unoptimized', 'true')
276
+ expect(logo).toHaveClass('object-contain')
277
+ expect(logo).not.toHaveClass('rounded-full')
274
278
  })
275
279
  expect(screen.getByText('Acme')).toBeInTheDocument()
276
280
  } finally {
@@ -280,6 +284,60 @@ describe('AppShell', () => {
280
284
  }
281
285
  })
282
286
 
287
+ it('uses the cropped icon treatment for backend chrome brand logos by default', async () => {
288
+ const previousFetch = global.fetch
289
+ const previousWindowFetch = window.fetch
290
+ const previousOriginalFetch = (window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch
291
+ const logoSrc = 'https://example.com/acme-wide-logo.webp'
292
+ const fetchMock = jest.fn().mockResolvedValue(
293
+ new Response(JSON.stringify({
294
+ brand: {
295
+ name: 'Acme',
296
+ logo: {
297
+ src: logoSrc,
298
+ alt: 'Acme logo',
299
+ },
300
+ },
301
+ groups,
302
+ settingsSections: [],
303
+ settingsPathPrefixes: [],
304
+ profileSections: [],
305
+ profilePathPrefixes: [],
306
+ grantedFeatures: [],
307
+ roles: [],
308
+ }), { status: 200, headers: { 'content-type': 'application/json' } }),
309
+ ) as typeof fetch
310
+ global.fetch = fetchMock
311
+ window.fetch = fetchMock
312
+ ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = fetchMock
313
+
314
+ try {
315
+ renderWithProviders(
316
+ <AppShell
317
+ email="demo@example.com"
318
+ groups={[]}
319
+ adminNavApi="/api/auth/admin/nav-brand-logo-cropped"
320
+ >
321
+ <div>Child content</div>
322
+ </AppShell>,
323
+ { dict },
324
+ )
325
+
326
+ await waitFor(() => {
327
+ const logo = screen.getByAltText('Acme logo')
328
+ expect(logo).toHaveAttribute('src', logoSrc)
329
+ expect(logo).toHaveAttribute('data-unoptimized', 'true')
330
+ expect(logo).toHaveClass('object-cover')
331
+ expect(logo).toHaveClass('rounded-full')
332
+ expect(logo).not.toHaveClass('object-contain')
333
+ })
334
+ } finally {
335
+ global.fetch = previousFetch
336
+ window.fetch = previousWindowFetch
337
+ ;(window as Window & { __omOriginalFetch?: typeof fetch }).__omOriginalFetch = previousOriginalFetch
338
+ }
339
+ })
340
+
283
341
  it('renders nested settings links when settings parent route is active', async () => {
284
342
  mockPathname = '/backend/entities/user'
285
343
 
@@ -91,7 +91,7 @@ export function PortalProvider({ orgSlug, children, initialAuth, initialTenant }
91
91
  },
92
92
  roles: [],
93
93
  resolvedFeatures: initialAuth.resolvedFeatures,
94
- isPortalAdmin: initialAuth.resolvedFeatures.some((f) => f === 'portal.*'),
94
+ isPortalAdmin: initialAuth.isPortalAdmin === true,
95
95
  loading: false,
96
96
  error: null,
97
97
  }
@@ -97,7 +97,7 @@ describe('buildPortalNav', () => {
97
97
  expect(groups).toEqual([{ id: 'main', items: [expect.objectContaining({ label: 'Orders' })] }])
98
98
  })
99
99
 
100
- it('bypasses feature checks when isPortalAdmin is true', () => {
100
+ it('does not let the deprecated portal-admin flag bypass effective features', () => {
101
101
  const routes: FrontendRouteManifestEntry[] = [
102
102
  makeRoute({
103
103
  pattern: '/[orgSlug]/portal/orders',
@@ -107,7 +107,7 @@ describe('buildPortalNav', () => {
107
107
  ]
108
108
 
109
109
  const groups = buildPortalNav({ routes, orgSlug: 'my-org', grantedFeatures: [], isPortalAdmin: true })
110
- expect(groups[0].items[0].label).toBe('Orders')
110
+ expect(groups).toEqual([])
111
111
  })
112
112
 
113
113
  it('ignores navHidden pages even when nav is declared', () => {
@@ -22,9 +22,9 @@ export type BuildPortalNavOptions = {
22
22
  routes: readonly FrontendRouteManifestEntry[]
23
23
  /** Current customer org slug — substituted into `[orgSlug]` patterns. */
24
24
  orgSlug: string
25
- /** Feature strings granted to the current customer (may include wildcards). */
25
+ /** Concrete effective features granted to the current customer. */
26
26
  grantedFeatures: readonly string[]
27
- /** If true, bypass feature checks (portal admin). Defaults to false. */
27
+ /** @deprecated Portal admins must receive concrete effective features. */
28
28
  isPortalAdmin?: boolean
29
29
  }
30
30
 
@@ -75,7 +75,6 @@ export function buildPortalNav({
75
75
  routes,
76
76
  orgSlug,
77
77
  grantedFeatures,
78
- isPortalAdmin = false,
79
78
  }: BuildPortalNavOptions): PortalNavGroup[] {
80
79
  const mainItems: PortalNavItem[] = []
81
80
  const accountItems: PortalNavItem[] = []
@@ -96,7 +95,7 @@ export function buildPortalNav({
96
95
  const nav = route.nav!
97
96
 
98
97
  const requireFeatures = route.requireCustomerFeatures ?? []
99
- if (!isPortalAdmin && requireFeatures.length) {
98
+ if (requireFeatures.length) {
100
99
  if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
101
100
  }
102
101