@open-mercato/ui 0.9.1-develop.7499.1.fcec456e4e → 0.9.1-develop.7515.1.e2712c8b00
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/dist/backend/CrudForm.js +56 -36
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/DataTable.js +40 -14
- package/dist/backend/DataTable.js.map +2 -2
- package/dist/backend/columns/ColumnChooserPanel.js +4 -3
- package/dist/backend/columns/ColumnChooserPanel.js.map +2 -2
- package/dist/backend/crud/injectedFieldGroups.js +50 -0
- package/dist/backend/crud/injectedFieldGroups.js.map +7 -0
- package/dist/backend/filters/FilterFieldPicker.js +3 -2
- package/dist/backend/filters/FilterFieldPicker.js.map +2 -2
- package/dist/backend/utils/api.js +25 -4
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/groupLabels.js +16 -0
- package/dist/backend/utils/groupLabels.js.map +7 -0
- package/dist/theme/QueryProvider.js +7 -1
- package/dist/theme/QueryProvider.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +68 -45
- package/src/backend/DataTable.tsx +54 -14
- package/src/backend/__tests__/CrudForm.fieldInjection.test.tsx +85 -4
- package/src/backend/__tests__/DataTable.injectedColumnPlacement.test.tsx +146 -0
- package/src/backend/columns/ColumnChooserPanel.tsx +4 -3
- package/src/backend/columns/__tests__/ColumnChooserPanel.groupLabels.test.tsx +72 -0
- package/src/backend/crud/__tests__/injectedFieldGroups.test.ts +90 -0
- package/src/backend/crud/injectedFieldGroups.ts +75 -0
- package/src/backend/filters/FilterFieldPicker.tsx +3 -2
- package/src/backend/filters/__tests__/FilterFieldPicker.groupLabels.test.tsx +77 -0
- package/src/backend/utils/__tests__/api.test.ts +67 -0
- package/src/backend/utils/api.ts +33 -4
- package/src/backend/utils/groupLabels.ts +26 -0
- package/src/theme/QueryProvider.tsx +7 -1
- package/src/theme/__tests__/QueryProvider.i18n.test.tsx +40 -0
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import type { InjectionFieldDefinition } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
|
+
import { InjectionPosition } from '@open-mercato/shared/modules/widgets/injection-position'
|
|
3
|
+
import type { CrudFormGroup } from '../../CrudForm'
|
|
4
|
+
import {
|
|
5
|
+
INJECTED_FIELDS_FALLBACK_GROUP_ID,
|
|
6
|
+
placeInjectedFieldsInGroups,
|
|
7
|
+
type InjectedFieldWidgetPlacement,
|
|
8
|
+
} from '../injectedFieldGroups'
|
|
9
|
+
|
|
10
|
+
function field(id: string, group?: string, extra: Partial<InjectionFieldDefinition> = {}): InjectionFieldDefinition {
|
|
11
|
+
return { id, label: id, type: 'text', group, ...extra } as InjectionFieldDefinition
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function fieldIds(group: CrudFormGroup | undefined): string[] {
|
|
15
|
+
return (group?.fields ?? []).map((entry) => (typeof entry === 'string' ? entry : entry.id))
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const groupPlacement: InjectedFieldWidgetPlacement = {
|
|
19
|
+
kind: 'group',
|
|
20
|
+
column: 2,
|
|
21
|
+
groupLabel: 'wms.widgets.catalog.inventoryProfile.groupLabel',
|
|
22
|
+
groupDescription: 'wms.widgets.catalog.inventoryProfile.groupDescription',
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
describe('placeInjectedFieldsInGroups (#6142)', () => {
|
|
26
|
+
it('inserts a field into the declared group it targets, honoring placement', () => {
|
|
27
|
+
const groups: CrudFormGroup[] = [{ id: 'general', fields: ['title', 'sku'] }]
|
|
28
|
+
const placed = placeInjectedFieldsInGroups(
|
|
29
|
+
groups,
|
|
30
|
+
[field('subtitle', 'general', { placement: { position: InjectionPosition.After, relativeTo: 'title' } })],
|
|
31
|
+
new Map(),
|
|
32
|
+
)
|
|
33
|
+
expect(fieldIds(placed[0])).toEqual(['title', 'subtitle', 'sku'])
|
|
34
|
+
expect(groups[0].fields).toEqual(['title', 'sku'])
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('renders fields of an undeclared group-kind injection as their own card', () => {
|
|
38
|
+
const groups: CrudFormGroup[] = [
|
|
39
|
+
{ id: 'general', fields: ['title'] },
|
|
40
|
+
{ id: 'custom', kind: 'customFields', title: 'Custom attributes' },
|
|
41
|
+
]
|
|
42
|
+
const definitions = [field('wms.manageInventory', 'wms.inventoryProfile'), field('wms.safetyStock', 'wms.inventoryProfile')]
|
|
43
|
+
const placements = new Map(definitions.map((definition) => [definition.id, groupPlacement]))
|
|
44
|
+
|
|
45
|
+
const placed = placeInjectedFieldsInGroups(groups, definitions, placements)
|
|
46
|
+
|
|
47
|
+
expect(placed.map((group) => group.id)).toEqual(['general', 'custom', 'wms.inventoryProfile'])
|
|
48
|
+
const card = placed[2]
|
|
49
|
+
expect(card.title).toBe(groupPlacement.groupLabel)
|
|
50
|
+
expect(card.description).toBe(groupPlacement.groupDescription)
|
|
51
|
+
expect(card.column).toBe(2)
|
|
52
|
+
expect(fieldIds(card)).toEqual(['wms.manageInventory', 'wms.safetyStock'])
|
|
53
|
+
expect(fieldIds(placed[0])).toEqual(['title'])
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
it('never falls back into a customFields group, which ignores group.fields', () => {
|
|
57
|
+
const fallbacks: string[] = []
|
|
58
|
+
const placed = placeInjectedFieldsInGroups(
|
|
59
|
+
[
|
|
60
|
+
{ id: 'general', fields: ['title'] },
|
|
61
|
+
{ id: 'custom', kind: 'customFields' },
|
|
62
|
+
],
|
|
63
|
+
[field('example.priority', 'missing')],
|
|
64
|
+
new Map(),
|
|
65
|
+
(definition) => fallbacks.push(definition.id),
|
|
66
|
+
)
|
|
67
|
+
expect(fieldIds(placed[0])).toEqual(['title', 'example.priority'])
|
|
68
|
+
expect(fieldIds(placed[1])).toEqual([])
|
|
69
|
+
expect(fallbacks).toEqual(['example.priority'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('synthesizes a host group when no declared group renders plain fields', () => {
|
|
73
|
+
const placed = placeInjectedFieldsInGroups(
|
|
74
|
+
[{ id: 'custom', kind: 'customFields' }],
|
|
75
|
+
[field('example.priority', 'missing')],
|
|
76
|
+
new Map(),
|
|
77
|
+
)
|
|
78
|
+
expect(placed.map((group) => group.id)).toEqual(['custom', INJECTED_FIELDS_FALLBACK_GROUP_ID])
|
|
79
|
+
expect(fieldIds(placed[1])).toEqual(['example.priority'])
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
it('does not duplicate a field the host already declares', () => {
|
|
83
|
+
const placed = placeInjectedFieldsInGroups(
|
|
84
|
+
[{ id: 'general', fields: ['title', 'subtitle'] }],
|
|
85
|
+
[field('subtitle', 'general')],
|
|
86
|
+
new Map(),
|
|
87
|
+
)
|
|
88
|
+
expect(fieldIds(placed[0])).toEqual(['title', 'subtitle'])
|
|
89
|
+
})
|
|
90
|
+
})
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { InjectionFieldDefinition } from '@open-mercato/shared/modules/widgets/injection'
|
|
2
|
+
import { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'
|
|
3
|
+
import type { CrudFormGroup } from '../CrudForm'
|
|
4
|
+
|
|
5
|
+
export type InjectedFieldWidgetPlacement = {
|
|
6
|
+
kind?: 'tab' | 'group' | 'stack'
|
|
7
|
+
groupLabel?: string
|
|
8
|
+
groupDescription?: string
|
|
9
|
+
column?: 1 | 2
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export const INJECTED_FIELDS_FALLBACK_GROUP_ID = '__injected-fields__'
|
|
13
|
+
|
|
14
|
+
function hostsPlainFields(group: CrudFormGroup): boolean {
|
|
15
|
+
return group.kind !== 'customFields' && !group.bare
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function entryId(entry: NonNullable<CrudFormGroup['fields']>[number]): string {
|
|
19
|
+
return typeof entry === 'string' ? entry : entry.id
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Places injected field definitions into the host form's groups.
|
|
24
|
+
*
|
|
25
|
+
* - A field whose `group` matches a declared group is inserted there (honoring `placement`).
|
|
26
|
+
* - A field whose `group` is not declared, injected by a widget mapped with `kind: 'group'`,
|
|
27
|
+
* gets its own card built from the injection-table `groupLabel`/`groupDescription`/`column`.
|
|
28
|
+
* - Any other field falls back to the last group that renders plain fields — never into a
|
|
29
|
+
* `customFields` or `bare` group, which do not render `group.fields` and would drop it.
|
|
30
|
+
* A field that explicitly names a `customFields` or `bare` group is still placed there and,
|
|
31
|
+
* like any non-field entry of such a group, is not rendered.
|
|
32
|
+
*/
|
|
33
|
+
export function placeInjectedFieldsInGroups(
|
|
34
|
+
groups: CrudFormGroup[],
|
|
35
|
+
definitions: InjectionFieldDefinition[],
|
|
36
|
+
placementByFieldId: ReadonlyMap<string, InjectedFieldWidgetPlacement | undefined>,
|
|
37
|
+
onFallback?: (definition: InjectionFieldDefinition) => void,
|
|
38
|
+
): CrudFormGroup[] {
|
|
39
|
+
const placed: CrudFormGroup[] = groups.map((group) => ({ ...group, fields: [...(group.fields ?? [])] }))
|
|
40
|
+
let fallbackIndex = -1
|
|
41
|
+
for (let index = placed.length - 1; index >= 0; index -= 1) {
|
|
42
|
+
if (hostsPlainFields(placed[index])) {
|
|
43
|
+
fallbackIndex = index
|
|
44
|
+
break
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const definition of definitions) {
|
|
49
|
+
let index = definition.group ? placed.findIndex((group) => group.id === definition.group) : -1
|
|
50
|
+
if (index < 0) {
|
|
51
|
+
const placement = placementByFieldId.get(definition.id)
|
|
52
|
+
if (definition.group && placement?.kind === 'group') {
|
|
53
|
+
placed.push({
|
|
54
|
+
id: definition.group,
|
|
55
|
+
title: placement.groupLabel,
|
|
56
|
+
description: placement.groupDescription,
|
|
57
|
+
column: placement.column === 2 ? 2 : 1,
|
|
58
|
+
fields: [],
|
|
59
|
+
})
|
|
60
|
+
index = placed.length - 1
|
|
61
|
+
} else {
|
|
62
|
+
onFallback?.(definition)
|
|
63
|
+
if (fallbackIndex < 0) {
|
|
64
|
+
placed.push({ id: INJECTED_FIELDS_FALLBACK_GROUP_ID, fields: [] })
|
|
65
|
+
fallbackIndex = placed.length - 1
|
|
66
|
+
}
|
|
67
|
+
index = fallbackIndex
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
const fieldEntries = placed[index].fields ?? []
|
|
71
|
+
if (fieldEntries.some((entry) => entryId(entry) === definition.id)) continue
|
|
72
|
+
placed[index].fields = insertByInjectionPlacement(fieldEntries, definition.id, definition.placement, entryId)
|
|
73
|
+
}
|
|
74
|
+
return placed
|
|
75
|
+
}
|
|
@@ -4,6 +4,7 @@ import * as React from 'react'
|
|
|
4
4
|
import { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'
|
|
5
5
|
import { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'
|
|
6
6
|
import { useT } from '@open-mercato/shared/lib/i18n/context'
|
|
7
|
+
import { UNGROUPED_GROUP_ID, resolveGroupLabel } from '../utils/groupLabels'
|
|
7
8
|
import type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
8
9
|
|
|
9
10
|
const TYPE_ICON: Record<FilterFieldType, LucideIcon> = {
|
|
@@ -54,7 +55,7 @@ export function FilterFieldPicker({ fields, open, onOpenChange, onSelect, trigge
|
|
|
54
55
|
const map = new Map<string, FilterFieldDef[]>()
|
|
55
56
|
const order: string[] = []
|
|
56
57
|
for (const f of filtered) {
|
|
57
|
-
const g = f.group ??
|
|
58
|
+
const g = f.group ?? UNGROUPED_GROUP_ID
|
|
58
59
|
if (!map.has(g)) { map.set(g, []); order.push(g) }
|
|
59
60
|
map.get(g)!.push(f)
|
|
60
61
|
}
|
|
@@ -103,7 +104,7 @@ export function FilterFieldPicker({ fields, open, onOpenChange, onSelect, trigge
|
|
|
103
104
|
<div className="max-h-[400px] overflow-y-auto py-1" role="listbox">
|
|
104
105
|
{grouped.map(({ group, items }) => (
|
|
105
106
|
<div key={group}>
|
|
106
|
-
<div className="px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground">{group}</div>
|
|
107
|
+
<div className="px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground">{resolveGroupLabel(t, group, 'ui.advancedFilter.fieldPicker.ungrouped', 'More')}</div>
|
|
107
108
|
{items.map((f) => {
|
|
108
109
|
const flatIdx = flatVisible.indexOf(f)
|
|
109
110
|
const Icon = resolveIcon(f)
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// packages/ui/src/backend/filters/__tests__/FilterFieldPicker.groupLabels.test.tsx
|
|
2
|
+
import * as React from 'react'
|
|
3
|
+
import { render, screen } from '@testing-library/react'
|
|
4
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
5
|
+
import type { Locale } from '@open-mercato/shared/lib/i18n/config'
|
|
6
|
+
import { FilterFieldPicker } from '../FilterFieldPicker'
|
|
7
|
+
import type { FilterFieldDef } from '@open-mercato/shared/lib/query/advanced-filter'
|
|
8
|
+
|
|
9
|
+
const fields: FilterFieldDef[] = [
|
|
10
|
+
{ key: 'owner', label: 'Owner', type: 'text', group: 'customers.columnGroups.crm' },
|
|
11
|
+
{ key: 'email', label: 'Email', type: 'text', group: 'Contact' },
|
|
12
|
+
{ key: 'ungrouped', label: 'Ungrouped', type: 'text' },
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function renderPicker(locale: Locale, dict: Record<string, string>, pickerFields = fields) {
|
|
16
|
+
return render(
|
|
17
|
+
<I18nProvider locale={locale} dict={dict}>
|
|
18
|
+
<FilterFieldPicker fields={pickerFields} open onSelect={() => {}} onOpenChange={() => {}} triggerRef={{ current: null }} />
|
|
19
|
+
</I18nProvider>,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('FilterFieldPicker group labels', () => {
|
|
24
|
+
it('translates group labels a host registered in its locale file', () => {
|
|
25
|
+
renderPicker('pl', {
|
|
26
|
+
'customers.columnGroups.crm': 'CRM PL',
|
|
27
|
+
'ui.advancedFilter.fieldPicker.ungrouped': 'Więcej',
|
|
28
|
+
})
|
|
29
|
+
expect(screen.getByText('CRM PL')).toBeInTheDocument()
|
|
30
|
+
expect(screen.queryByText('customers.columnGroups.crm')).not.toBeInTheDocument()
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('renders unrecognized group strings verbatim', () => {
|
|
34
|
+
renderPicker('pl', { 'customers.columnGroups.crm': 'CRM PL' })
|
|
35
|
+
expect(screen.getByText('Contact')).toBeInTheDocument()
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('translates the fallback group label for fields without a group', () => {
|
|
39
|
+
renderPicker('pl', { 'ui.advancedFilter.fieldPicker.ungrouped': 'Więcej' })
|
|
40
|
+
expect(screen.getByText('Więcej')).toBeInTheDocument()
|
|
41
|
+
expect(screen.queryByText('More')).not.toBeInTheDocument()
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
it('falls back to the English label when no translation is registered', () => {
|
|
45
|
+
renderPicker('en', {})
|
|
46
|
+
expect(screen.getByText('More')).toBeInTheDocument()
|
|
47
|
+
expect(screen.getByText('Contact')).toBeInTheDocument()
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it('never resolves tenant-authored group titles as translation keys', () => {
|
|
51
|
+
renderPicker('pl', { 'Deal pipeline': 'Lejek sprzedaży' }, [
|
|
52
|
+
{ key: 'stage', label: 'Stage', type: 'text', group: 'Deal pipeline' },
|
|
53
|
+
])
|
|
54
|
+
expect(screen.getByText('Deal pipeline')).toBeInTheDocument()
|
|
55
|
+
expect(screen.queryByText('Lejek sprzedaży')).not.toBeInTheDocument()
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
it('keeps ungrouped identity locale-independent instead of splitting on the translated label', () => {
|
|
59
|
+
renderPicker('pl', { 'ui.advancedFilter.fieldPicker.ungrouped': 'Więcej' }, [
|
|
60
|
+
{ key: 'ungrouped', label: 'Ungrouped', type: 'text' },
|
|
61
|
+
{ key: 'more', label: 'More field', type: 'text', group: 'More' },
|
|
62
|
+
])
|
|
63
|
+
expect(screen.getByText('Więcej')).toBeInTheDocument()
|
|
64
|
+
expect(screen.getByText('More')).toBeInTheDocument()
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
it('keys grouping on the raw group string so two groups sharing a translation stay apart', () => {
|
|
68
|
+
renderPicker('pl', {
|
|
69
|
+
'customers.columnGroups.crm': 'CRM',
|
|
70
|
+
'customers.columnGroups.pipeline': 'CRM',
|
|
71
|
+
}, [
|
|
72
|
+
{ key: 'owner', label: 'Owner', type: 'text', group: 'customers.columnGroups.crm' },
|
|
73
|
+
{ key: 'stage', label: 'Stage', type: 'text', group: 'customers.columnGroups.pipeline' },
|
|
74
|
+
])
|
|
75
|
+
expect(screen.getAllByText('CRM')).toHaveLength(2)
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -10,7 +10,11 @@ import {
|
|
|
10
10
|
ForbiddenError,
|
|
11
11
|
UnauthorizedError,
|
|
12
12
|
apiFetch,
|
|
13
|
+
notifyForbiddenAccess,
|
|
14
|
+
redirectToSessionRefresh,
|
|
15
|
+
registerAuthNoticeTranslator,
|
|
13
16
|
} from '../../utils/api'
|
|
17
|
+
import { createTranslator } from '@open-mercato/shared/lib/i18n/translate'
|
|
14
18
|
|
|
15
19
|
function createMockResponse(
|
|
16
20
|
status: number,
|
|
@@ -227,3 +231,66 @@ describe('apiFetch', () => {
|
|
|
227
231
|
expect(flash).toHaveBeenCalledTimes(1)
|
|
228
232
|
})
|
|
229
233
|
})
|
|
234
|
+
|
|
235
|
+
describe('auth notice banners i18n (GH #7381)', () => {
|
|
236
|
+
const polishTranslator = createTranslator({
|
|
237
|
+
'ui.auth.sessionExpired': 'Sesja wygasła. Przekierowanie do logowania…',
|
|
238
|
+
'ui.auth.accessDenied.missingFeatures': 'Odmowa dostępu: brakuje uprawnienia „{features}”.',
|
|
239
|
+
'ui.auth.accessDenied.requiredRoles': 'Odmowa dostępu: wymagana rola „{roles}”.',
|
|
240
|
+
'ui.auth.accessDenied.generic': 'Odmowa dostępu.',
|
|
241
|
+
})
|
|
242
|
+
let unregister: (() => void) | null = null
|
|
243
|
+
|
|
244
|
+
beforeEach(() => {
|
|
245
|
+
jest.resetAllMocks()
|
|
246
|
+
jest.useFakeTimers()
|
|
247
|
+
window.history.pushState({}, '', '/backend/customers/people')
|
|
248
|
+
window.sessionStorage.clear()
|
|
249
|
+
})
|
|
250
|
+
|
|
251
|
+
afterEach(() => {
|
|
252
|
+
unregister?.()
|
|
253
|
+
unregister = null
|
|
254
|
+
jest.clearAllTimers()
|
|
255
|
+
jest.useRealTimers()
|
|
256
|
+
})
|
|
257
|
+
|
|
258
|
+
it('translates the session-expired banner with the registered translator', () => {
|
|
259
|
+
unregister = registerAuthNoticeTranslator(polishTranslator)
|
|
260
|
+
|
|
261
|
+
redirectToSessionRefresh()
|
|
262
|
+
|
|
263
|
+
expect(flash).toHaveBeenCalledWith('Sesja wygasła. Przekierowanie do logowania…', 'warning')
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('translates the access-denied banners and interpolates features and roles', () => {
|
|
267
|
+
unregister = registerAuthNoticeTranslator(polishTranslator)
|
|
268
|
+
|
|
269
|
+
notifyForbiddenAccess({ requiredFeatures: ['dashboards.view'] })
|
|
270
|
+
notifyForbiddenAccess({ requiredRoles: ['admin'] })
|
|
271
|
+
|
|
272
|
+
expect(flash).toHaveBeenNthCalledWith(1, 'Odmowa dostępu: brakuje uprawnienia „dashboards.view”.', 'warning')
|
|
273
|
+
expect(flash).toHaveBeenNthCalledWith(2, 'Odmowa dostępu: wymagana rola „admin”.', 'warning')
|
|
274
|
+
})
|
|
275
|
+
|
|
276
|
+
it('falls back to English copy once the translator is unregistered', () => {
|
|
277
|
+
registerAuthNoticeTranslator(polishTranslator)()
|
|
278
|
+
|
|
279
|
+
notifyForbiddenAccess({ requiredFeatures: ['dashboards.view'] })
|
|
280
|
+
|
|
281
|
+
expect(flash).toHaveBeenCalledWith(
|
|
282
|
+
'Access denied: you are missing the required permission "dashboards.view". Contact your administrator.',
|
|
283
|
+
'warning',
|
|
284
|
+
)
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
it('keeps a newer translator when an older registration is cleaned up', () => {
|
|
288
|
+
const releaseOlder = registerAuthNoticeTranslator(createTranslator({}))
|
|
289
|
+
unregister = registerAuthNoticeTranslator(polishTranslator)
|
|
290
|
+
releaseOlder()
|
|
291
|
+
|
|
292
|
+
redirectToSessionRefresh()
|
|
293
|
+
|
|
294
|
+
expect(flash).toHaveBeenCalledWith('Sesja wygasła. Przekierowanie do logowania…', 'warning')
|
|
295
|
+
})
|
|
296
|
+
})
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -8,9 +8,30 @@ import { pushOperation } from '../operations/store'
|
|
|
8
8
|
import { pushPartialIndexWarning } from '../indexes/store'
|
|
9
9
|
import { createScopedHeaderStack } from './scopedHeaderStack'
|
|
10
10
|
import { createLogger } from '@open-mercato/shared/lib/logger'
|
|
11
|
+
import type { TranslateFn, TranslateParams } from '@open-mercato/shared/lib/i18n/context'
|
|
12
|
+
import { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'
|
|
11
13
|
|
|
12
14
|
const logger = createLogger('ui').child({ component: 'apiFetch' })
|
|
13
15
|
|
|
16
|
+
const fallbackAuthNoticeTranslator: TranslateFn = (key) => key
|
|
17
|
+
let authNoticeTranslator: TranslateFn | null = null
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Register the active i18n translator for the session-expired and
|
|
21
|
+
* access-denied flash banners, which fire outside React. Returns a cleanup
|
|
22
|
+
* that unregisters it unless another translator replaced it meanwhile.
|
|
23
|
+
*/
|
|
24
|
+
export function registerAuthNoticeTranslator(translate: TranslateFn): () => void {
|
|
25
|
+
authNoticeTranslator = translate
|
|
26
|
+
return () => {
|
|
27
|
+
if (authNoticeTranslator === translate) authNoticeTranslator = null
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function translateAuthNotice(key: string, fallback: string, params?: TranslateParams): string {
|
|
32
|
+
return translateWithFallback(authNoticeTranslator ?? fallbackAuthNoticeTranslator, key, fallback, params)
|
|
33
|
+
}
|
|
34
|
+
|
|
14
35
|
const scopedHeaders = createScopedHeaderStack()
|
|
15
36
|
|
|
16
37
|
function mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {
|
|
@@ -106,7 +127,7 @@ export function redirectToSessionRefresh() {
|
|
|
106
127
|
if (recentlyAttemptedSessionRefresh(current)) return
|
|
107
128
|
recordSessionRefreshAttempt(current)
|
|
108
129
|
try {
|
|
109
|
-
flash('Session expired. Redirecting to sign in…', 'warning')
|
|
130
|
+
flash(translateAuthNotice('ui.auth.sessionExpired', 'Session expired. Redirecting to sign in…'), 'warning')
|
|
110
131
|
setTimeout(() => {
|
|
111
132
|
window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`
|
|
112
133
|
}, 20)
|
|
@@ -144,12 +165,20 @@ function formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | nul
|
|
|
144
165
|
const roles = options?.requiredRoles?.filter(Boolean) ?? []
|
|
145
166
|
const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)
|
|
146
167
|
if (features.length) {
|
|
147
|
-
return
|
|
168
|
+
return translateAuthNotice(
|
|
169
|
+
'ui.auth.accessDenied.missingFeatures',
|
|
170
|
+
'Access denied: you are missing the required permission "{features}". Contact your administrator.',
|
|
171
|
+
{ features: features.join(', ') },
|
|
172
|
+
)
|
|
148
173
|
}
|
|
149
174
|
if (effectiveRoles.length) {
|
|
150
|
-
return
|
|
175
|
+
return translateAuthNotice(
|
|
176
|
+
'ui.auth.accessDenied.requiredRoles',
|
|
177
|
+
'Access denied: this area requires the role "{roles}". Contact your administrator.',
|
|
178
|
+
{ roles: effectiveRoles.join(', ') },
|
|
179
|
+
)
|
|
151
180
|
}
|
|
152
|
-
return 'Access denied: you do not have permission to perform this action.'
|
|
181
|
+
return translateAuthNotice('ui.auth.accessDenied.generic', 'Access denied: you do not have permission to perform this action.')
|
|
153
182
|
}
|
|
154
183
|
|
|
155
184
|
/**
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// packages/ui/src/backend/utils/groupLabels.ts
|
|
2
|
+
|
|
3
|
+
// Stable, locale-independent identity for the "no declared group" bucket. Grouping and
|
|
4
|
+
// collapse state key on this sentinel so two locales never split one bucket in two.
|
|
5
|
+
export const UNGROUPED_GROUP_ID = '__om_ungrouped__'
|
|
6
|
+
|
|
7
|
+
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+$/
|
|
8
|
+
|
|
9
|
+
// Group strings reach these components from two very different places: code literals a
|
|
10
|
+
// module author controls, and free text a tenant types into the custom-field fieldset
|
|
11
|
+
// editor. Only the former may be resolved as a translation key, so tenant data is never
|
|
12
|
+
// silently reinterpreted as a dictionary lookup.
|
|
13
|
+
export function isTranslationKeyShaped(group: string): boolean {
|
|
14
|
+
return TRANSLATION_KEY_PATTERN.test(group)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function resolveGroupLabel(
|
|
18
|
+
translate: (key: string, fallback?: string) => string,
|
|
19
|
+
group: string,
|
|
20
|
+
ungroupedKey: string,
|
|
21
|
+
ungroupedFallback: string,
|
|
22
|
+
): string {
|
|
23
|
+
if (group === UNGROUPED_GROUP_ID) return translate(ungroupedKey, ungroupedFallback)
|
|
24
|
+
if (!isTranslationKeyShaped(group)) return group
|
|
25
|
+
return translate(group, group)
|
|
26
|
+
}
|
|
@@ -2,7 +2,8 @@
|
|
|
2
2
|
import * as React from 'react'
|
|
3
3
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
|
4
4
|
import { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'
|
|
5
|
-
import {
|
|
5
|
+
import { useOptionalT } from '@open-mercato/shared/lib/i18n/context'
|
|
6
|
+
import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig, registerAuthNoticeTranslator } from '../backend/utils/api'
|
|
6
7
|
|
|
7
8
|
// Ensure global fetch calls also flow through apiFetch so 401 session-refresh
|
|
8
9
|
// and 403 access-denied flash banners fire consistently.
|
|
@@ -68,6 +69,11 @@ const client = new QueryClient({
|
|
|
68
69
|
type QueryProviderProps = { children: React.ReactNode; defaultForbiddenRoles?: string[] }
|
|
69
70
|
|
|
70
71
|
export function QueryProvider({ children, defaultForbiddenRoles }: QueryProviderProps) {
|
|
72
|
+
const t = useOptionalT()
|
|
73
|
+
React.useEffect(() => {
|
|
74
|
+
if (!t) return
|
|
75
|
+
return registerAuthNoticeTranslator(t)
|
|
76
|
+
}, [t])
|
|
71
77
|
React.useEffect(() => {
|
|
72
78
|
ensureGlobalFetchInterception()
|
|
73
79
|
if (defaultForbiddenRoles && defaultForbiddenRoles.length) {
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*/
|
|
4
|
+
jest.mock('../../backend/FlashMessages', () => ({
|
|
5
|
+
flash: jest.fn(),
|
|
6
|
+
}))
|
|
7
|
+
|
|
8
|
+
import { render } from '@testing-library/react'
|
|
9
|
+
import { I18nProvider } from '@open-mercato/shared/lib/i18n/context'
|
|
10
|
+
import { flash } from '../../backend/FlashMessages'
|
|
11
|
+
import { redirectToSessionRefresh } from '../../backend/utils/api'
|
|
12
|
+
import { QueryProvider } from '../QueryProvider'
|
|
13
|
+
|
|
14
|
+
describe('QueryProvider auth notice translator (GH #7381)', () => {
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
jest.useFakeTimers()
|
|
17
|
+
window.history.pushState({}, '', '/backend/customers/people')
|
|
18
|
+
window.sessionStorage.clear()
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
afterEach(() => {
|
|
22
|
+
jest.clearAllTimers()
|
|
23
|
+
jest.useRealTimers()
|
|
24
|
+
})
|
|
25
|
+
|
|
26
|
+
it('shows the session-expired banner in the active locale', () => {
|
|
27
|
+
const view = render(
|
|
28
|
+
<I18nProvider locale="pl" dict={{ 'ui.auth.sessionExpired': 'Sesja wygasła. Przekierowanie do logowania…' }}>
|
|
29
|
+
<QueryProvider>
|
|
30
|
+
<div />
|
|
31
|
+
</QueryProvider>
|
|
32
|
+
</I18nProvider>,
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
redirectToSessionRefresh()
|
|
36
|
+
view.unmount()
|
|
37
|
+
|
|
38
|
+
expect(flash).toHaveBeenCalledWith('Sesja wygasła. Przekierowanie do logowania…', 'warning')
|
|
39
|
+
})
|
|
40
|
+
})
|