@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.
- package/dist/ai/AiChat.js +21 -2
- package/dist/ai/AiChat.js.map +2 -2
- package/dist/backend/AppShell.js +43 -4
- package/dist/backend/AppShell.js.map +2 -2
- package/dist/portal/PortalContext.js +1 -1
- package/dist/portal/PortalContext.js.map +2 -2
- package/dist/portal/utils/nav.js +2 -3
- package/dist/portal/utils/nav.js.map +2 -2
- package/package.json +3 -3
- package/src/ai/AiChat.tsx +30 -1
- package/src/ai/__tests__/AiChat.test.tsx +31 -0
- package/src/backend/AppShell.tsx +55 -4
- package/src/backend/__tests__/AppShell.test.tsx +65 -7
- package/src/portal/PortalContext.tsx +1 -1
- package/src/portal/utils/__tests__/nav.test.ts +2 -2
- package/src/portal/utils/nav.ts +3 -4
package/dist/portal/utils/nav.js
CHANGED
|
@@ -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 (
|
|
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 /**
|
|
5
|
-
"mappings": "AACA,SAAS,sBAAsB;AA6B/B,SAAS,gBAAgB,SAAgD;AACvE,MAAI,CAAC,QAAS,QAAO;AACrB,SAAO,QAAQ,WAAW,oBAAoB,KAAK,YAAY;AACjE;AAEA,SAAS,sBAAsB,MAAuB;AACpD,SAAO,CAAC,KAAK,SAAS,GAAG;AAC3B;AAEA,SAAS,YAAY,SAAiB,SAAyB;AAC7D,SAAO,QAAQ,QAAQ,aAAa,OAAO;AAC7C;AAEA,SAAS,UAAU,OAAkC;AACnD,MAAI,UAAU,UAAU,UAAU,UAAW,QAAO;AACpD,SAAO;AACT;AAEA,SAAS,mBACP,UACA,WAC4B;AAC5B,QAAM,mBAAmB,SAAS,2BAA2B,CAAC;AAC9D,QAAM,oBAAoB,UAAU,2BAA2B,CAAC;AAChE,MAAI,iBAAiB,WAAW,KAAK,kBAAkB,SAAS,EAAG,QAAO;AAC1E,MAAI,kBAAkB,WAAW,KAAK,iBAAiB,SAAS,EAAG,QAAO;AAC1E,MAAI,iBAAiB,WAAW,kBAAkB,QAAQ;AACxD,WAAO,kBAAkB,SAAS,iBAAiB,SAAS,YAAY;AAAA,EAC1E;AACA,SAAO;AACT;AAaO,SAAS,eAAe;AAAA,EAC7B;AAAA,EACA;AAAA,EACA;
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
package/src/backend/AppShell.tsx
CHANGED
|
@@ -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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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
|
-
<
|
|
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(
|
|
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:
|
|
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=
|
|
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.
|
|
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('
|
|
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
|
|
110
|
+
expect(groups).toEqual([])
|
|
111
111
|
})
|
|
112
112
|
|
|
113
113
|
it('ignores navHidden pages even when nav is declared', () => {
|
package/src/portal/utils/nav.ts
CHANGED
|
@@ -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
|
-
/**
|
|
25
|
+
/** Concrete effective features granted to the current customer. */
|
|
26
26
|
grantedFeatures: readonly string[]
|
|
27
|
-
/**
|
|
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 (
|
|
98
|
+
if (requireFeatures.length) {
|
|
100
99
|
if (!hasAllFeatures(grantedFeatures as string[], requireFeatures as string[])) continue
|
|
101
100
|
}
|
|
102
101
|
|