@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
|
@@ -7,6 +7,7 @@ import { IconButton } from "../../primitives/icon-button.js";
|
|
|
7
7
|
import { Switch } from "../../primitives/switch.js";
|
|
8
8
|
import { SearchInput } from "../../primitives/search-input.js";
|
|
9
9
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
10
|
+
import { UNGROUPED_GROUP_ID, resolveGroupLabel } from "../utils/groupLabels.js";
|
|
10
11
|
import {
|
|
11
12
|
DndContext,
|
|
12
13
|
closestCenter,
|
|
@@ -97,13 +98,13 @@ function ColumnChooserSection({
|
|
|
97
98
|
const filtered = availableColumns.filter((c) => !visibleSet.has(c.key)).filter((c) => !searchQuery || c.label.toLowerCase().includes(lowerQuery));
|
|
98
99
|
const groups = /* @__PURE__ */ new Map();
|
|
99
100
|
for (const col of filtered) {
|
|
100
|
-
const group = col.group ||
|
|
101
|
+
const group = col.group || UNGROUPED_GROUP_ID;
|
|
101
102
|
const list = groups.get(group) ?? [];
|
|
102
103
|
list.push(col);
|
|
103
104
|
groups.set(group, list);
|
|
104
105
|
}
|
|
105
106
|
return groups;
|
|
106
|
-
}, [availableColumns, searchQuery, visibleSet
|
|
107
|
+
}, [availableColumns, searchQuery, visibleSet]);
|
|
107
108
|
const toggleGroup = React.useCallback((group) => {
|
|
108
109
|
setExpandedGroups((prev) => {
|
|
109
110
|
const next = new Set(prev);
|
|
@@ -178,7 +179,7 @@ function ColumnChooserSection({
|
|
|
178
179
|
onClick: () => toggleGroup(group),
|
|
179
180
|
children: [
|
|
180
181
|
/* @__PURE__ */ jsx(ChevronRight, { className: `size-4 transition-transform ${isCollapsed ? "" : "rotate-90"}` }),
|
|
181
|
-
/* @__PURE__ */ jsx("span", { children: group })
|
|
182
|
+
/* @__PURE__ */ jsx("span", { children: resolveGroupLabel(t, group, "ui.columnChooser.ungrouped", "Other") })
|
|
182
183
|
]
|
|
183
184
|
}
|
|
184
185
|
),
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/columns/ColumnChooserPanel.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { GripVertical, X, ChevronRight } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Switch } from '../../primitives/switch'\nimport { Input } from '../../primitives/input'\nimport { SearchInput } from '../../primitives/search-input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n DndContext,\n closestCenter,\n KeyboardSensor,\n PointerSensor,\n useSensor,\n useSensors,\n type DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n SortableContext,\n sortableKeyboardCoordinates,\n verticalListSortingStrategy,\n useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\n\nexport type ColumnChooserField = {\n key: string\n label: string\n group: string\n defaultVisible?: boolean\n alwaysVisible?: boolean\n}\n\nexport type ColumnChooserSectionProps = {\n availableColumns: ColumnChooserField[]\n visibleColumnKeys: string[]\n columnOrder: string[]\n onToggleColumn: (key: string) => void\n onReorderColumns: (newOrder: string[]) => void\n dndContextId?: string\n}\n\nexport type ColumnChooserPanelProps = ColumnChooserSectionProps & {\n open: boolean\n onOpenChange: (open: boolean) => void\n}\n\nfunction SortableColumnItem({\n column,\n onToggle,\n}: {\n column: ColumnChooserField\n onToggle: (key: string) => void\n}) {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: column.key })\n\n const style: React.CSSProperties = {\n transform: CSS.Transform.toString(transform),\n transition,\n opacity: isDragging ? 0.5 : 1,\n }\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n className=\"flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50\"\n >\n <span className=\"cursor-grab text-muted-foreground\" {...attributes} {...listeners}>\n <GripVertical className=\"size-4\" />\n </span>\n <span className=\"truncate flex-1 min-w-0\">{column.label}</span>\n <Switch\n checked\n disabled={column.alwaysVisible}\n onCheckedChange={() => onToggle(column.key)}\n className=\"shrink-0 scale-90\"\n />\n </div>\n )\n}\n\nexport function ColumnChooserSection({\n availableColumns,\n visibleColumnKeys,\n columnOrder,\n onToggleColumn,\n onReorderColumns,\n dndContextId = 'column-chooser',\n}: ColumnChooserSectionProps) {\n const t = useT()\n const [searchQuery, setSearchQuery] = React.useState('')\n const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(new Set())\n\n const sensors = useSensors(\n useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),\n )\n\n const visibleSet = React.useMemo(() => new Set(visibleColumnKeys), [visibleColumnKeys])\n\n const selectedColumns = React.useMemo(() => {\n const ordered: ColumnChooserField[] = []\n for (const key of columnOrder) {\n const col = availableColumns.find((c) => c.key === key)\n if (col && visibleSet.has(key)) ordered.push(col)\n }\n for (const col of availableColumns) {\n if (visibleSet.has(col.key) && !ordered.some((o) => o.key === col.key)) {\n ordered.push(col)\n }\n }\n if (!searchQuery) return ordered\n const lowerQuery = searchQuery.toLowerCase()\n return ordered.filter((c) => c.label.toLowerCase().includes(lowerQuery))\n }, [availableColumns, visibleSet, columnOrder, searchQuery])\n\n const groupedAvailable = React.useMemo(() => {\n const lowerQuery = searchQuery.toLowerCase()\n const filtered = availableColumns\n .filter((c) => !visibleSet.has(c.key))\n .filter((c) => !searchQuery || c.label.toLowerCase().includes(lowerQuery))\n\n const groups = new Map<string, ColumnChooserField[]>()\n for (const col of filtered) {\n const group = col.group || t('ui.columnChooser.ungrouped', 'Other')\n const list = groups.get(group) ?? []\n list.push(col)\n groups.set(group, list)\n }\n return groups\n }, [availableColumns, searchQuery, visibleSet, t])\n\n const toggleGroup = React.useCallback((group: string) => {\n setExpandedGroups((prev) => {\n const next = new Set(prev)\n if (next.has(group)) next.delete(group)\n else next.add(group)\n return next\n })\n }, [])\n\n const handleDragEnd = React.useCallback((event: DragEndEvent) => {\n const { active, over } = event\n if (!over || active.id === over.id) return\n const oldIndex = selectedColumns.findIndex((c) => c.key === active.id)\n const newIndex = selectedColumns.findIndex((c) => c.key === over.id)\n if (oldIndex === -1 || newIndex === -1) return\n const reordered = [...selectedColumns]\n const [moved] = reordered.splice(oldIndex, 1)\n reordered.splice(newIndex, 0, moved)\n onReorderColumns(reordered.map((c) => c.key))\n }, [selectedColumns, onReorderColumns])\n\n const handleHideAll = React.useCallback(() => {\n for (const col of selectedColumns) {\n if (!col.alwaysVisible) onToggleColumn(col.key)\n }\n }, [selectedColumns, onToggleColumn])\n\n return (\n <div className=\"flex flex-col\">\n <div className=\"px-4 py-3 border-t\">\n <SearchInput\n size=\"sm\"\n placeholder={t('ui.columnChooser.search', 'Search columns...')}\n value={searchQuery}\n onChange={setSearchQuery}\n />\n </div>\n\n <div>\n {selectedColumns.length > 0 ? (\n <div className=\"px-4 py-3\">\n <div className=\"flex items-center justify-between mb-3\">\n <div className=\"flex items-baseline gap-2\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n {t('ui.columnChooser.shown', 'Shown')}\n </div>\n <span className=\"text-xs text-muted-foreground\">\n {selectedColumns.length}/{availableColumns.length}\n </span>\n </div>\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"h-auto px-0 text-sm text-muted-foreground hover:text-foreground\"\n onClick={handleHideAll}\n >\n {t('ui.columnChooser.hideAll', 'Hide all')}\n </Button>\n </div>\n <DndContext id={dndContextId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>\n <SortableContext items={selectedColumns.map((c) => c.key)} strategy={verticalListSortingStrategy}>\n <div className=\"space-y-2\">\n {selectedColumns.map((col) => (\n <SortableColumnItem key={col.key} column={col} onToggle={onToggleColumn} />\n ))}\n </div>\n </SortableContext>\n </DndContext>\n </div>\n ) : null}\n\n <div className=\"border-t px-4 py-3 mt-4 mb-4\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3\">\n {t('ui.columnChooser.available', 'Available columns')}\n </div>\n {Array.from(groupedAvailable.entries()).map(([group, columns]) => {\n const isCollapsed = !searchQuery && !expandedGroups.has(group)\n return (\n <div key={group} className=\"mb-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start gap-2 h-auto px-1 py-2 text-xs font-medium uppercase text-muted-foreground\"\n onClick={() => toggleGroup(group)}\n >\n <ChevronRight className={`size-4 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />\n <span>{group}</span>\n </Button>\n {!isCollapsed ? (\n <div className=\"space-y-2 mt-2\">\n {columns.map((col) => (\n <div\n key={col.key}\n className=\"flex items-center gap-2 rounded pl-7 pr-2 py-1.5 text-sm hover:bg-muted/50 cursor-pointer\"\n onClick={() => { if (!col.alwaysVisible) onToggleColumn(col.key) }}\n >\n <span className=\"truncate flex-1 min-w-0\">{col.label}</span>\n <Switch\n checked={false}\n disabled={col.alwaysVisible}\n onCheckedChange={() => onToggleColumn(col.key)}\n onClick={(e) => e.stopPropagation()}\n className=\"shrink-0 scale-90\"\n />\n </div>\n ))}\n </div>\n ) : null}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n\nexport function ColumnChooserPanel({\n open,\n onOpenChange,\n ...sectionProps\n}: ColumnChooserPanelProps) {\n const t = useT()\n React.useEffect(() => {\n if (!open) return\n if (typeof document === 'undefined') return\n document.body.dataset.columnChooserOpen = 'true'\n return () => {\n delete document.body.dataset.columnChooserOpen\n }\n }, [open])\n if (!open) return null\n return (\n <div className=\"fixed inset-y-0 right-0 z-modal w-80 border-l bg-background shadow-lg flex flex-col\">\n <div className=\"flex items-center justify-between border-b px-4 py-3\">\n <h3 className=\"font-semibold text-sm\">\n {t('ui.columnChooser.title', 'Columns')}\n </h3>\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => onOpenChange(false)} aria-label={t('ui.columnChooser.close', 'Close')}>\n <X className=\"size-4\" />\n </IconButton>\n </div>\n <div className=\"flex-1 overflow-auto\">\n <ColumnChooserSection {...sectionProps} />\n </div>\n </div>\n )\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { GripVertical, X, ChevronRight } from 'lucide-react'\nimport { Button } from '../../primitives/button'\nimport { IconButton } from '../../primitives/icon-button'\nimport { Switch } from '../../primitives/switch'\nimport { Input } from '../../primitives/input'\nimport { SearchInput } from '../../primitives/search-input'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { UNGROUPED_GROUP_ID, resolveGroupLabel } from '../utils/groupLabels'\nimport {\n DndContext,\n closestCenter,\n KeyboardSensor,\n PointerSensor,\n useSensor,\n useSensors,\n type DragEndEvent,\n} from '@dnd-kit/core'\nimport {\n SortableContext,\n sortableKeyboardCoordinates,\n verticalListSortingStrategy,\n useSortable,\n} from '@dnd-kit/sortable'\nimport { CSS } from '@dnd-kit/utilities'\n\nexport type ColumnChooserField = {\n key: string\n label: string\n group: string\n defaultVisible?: boolean\n alwaysVisible?: boolean\n}\n\nexport type ColumnChooserSectionProps = {\n availableColumns: ColumnChooserField[]\n visibleColumnKeys: string[]\n columnOrder: string[]\n onToggleColumn: (key: string) => void\n onReorderColumns: (newOrder: string[]) => void\n dndContextId?: string\n}\n\nexport type ColumnChooserPanelProps = ColumnChooserSectionProps & {\n open: boolean\n onOpenChange: (open: boolean) => void\n}\n\nfunction SortableColumnItem({\n column,\n onToggle,\n}: {\n column: ColumnChooserField\n onToggle: (key: string) => void\n}) {\n const {\n attributes,\n listeners,\n setNodeRef,\n transform,\n transition,\n isDragging,\n } = useSortable({ id: column.key })\n\n const style: React.CSSProperties = {\n transform: CSS.Transform.toString(transform),\n transition,\n opacity: isDragging ? 0.5 : 1,\n }\n\n return (\n <div\n ref={setNodeRef}\n style={style}\n className=\"flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-muted/50\"\n >\n <span className=\"cursor-grab text-muted-foreground\" {...attributes} {...listeners}>\n <GripVertical className=\"size-4\" />\n </span>\n <span className=\"truncate flex-1 min-w-0\">{column.label}</span>\n <Switch\n checked\n disabled={column.alwaysVisible}\n onCheckedChange={() => onToggle(column.key)}\n className=\"shrink-0 scale-90\"\n />\n </div>\n )\n}\n\nexport function ColumnChooserSection({\n availableColumns,\n visibleColumnKeys,\n columnOrder,\n onToggleColumn,\n onReorderColumns,\n dndContextId = 'column-chooser',\n}: ColumnChooserSectionProps) {\n const t = useT()\n const [searchQuery, setSearchQuery] = React.useState('')\n const [expandedGroups, setExpandedGroups] = React.useState<Set<string>>(new Set())\n\n const sensors = useSensors(\n useSensor(PointerSensor, { activationConstraint: { distance: 5 } }),\n useSensor(KeyboardSensor, { coordinateGetter: sortableKeyboardCoordinates }),\n )\n\n const visibleSet = React.useMemo(() => new Set(visibleColumnKeys), [visibleColumnKeys])\n\n const selectedColumns = React.useMemo(() => {\n const ordered: ColumnChooserField[] = []\n for (const key of columnOrder) {\n const col = availableColumns.find((c) => c.key === key)\n if (col && visibleSet.has(key)) ordered.push(col)\n }\n for (const col of availableColumns) {\n if (visibleSet.has(col.key) && !ordered.some((o) => o.key === col.key)) {\n ordered.push(col)\n }\n }\n if (!searchQuery) return ordered\n const lowerQuery = searchQuery.toLowerCase()\n return ordered.filter((c) => c.label.toLowerCase().includes(lowerQuery))\n }, [availableColumns, visibleSet, columnOrder, searchQuery])\n\n const groupedAvailable = React.useMemo(() => {\n const lowerQuery = searchQuery.toLowerCase()\n const filtered = availableColumns\n .filter((c) => !visibleSet.has(c.key))\n .filter((c) => !searchQuery || c.label.toLowerCase().includes(lowerQuery))\n\n const groups = new Map<string, ColumnChooserField[]>()\n for (const col of filtered) {\n const group = col.group || UNGROUPED_GROUP_ID\n const list = groups.get(group) ?? []\n list.push(col)\n groups.set(group, list)\n }\n return groups\n }, [availableColumns, searchQuery, visibleSet])\n\n const toggleGroup = React.useCallback((group: string) => {\n setExpandedGroups((prev) => {\n const next = new Set(prev)\n if (next.has(group)) next.delete(group)\n else next.add(group)\n return next\n })\n }, [])\n\n const handleDragEnd = React.useCallback((event: DragEndEvent) => {\n const { active, over } = event\n if (!over || active.id === over.id) return\n const oldIndex = selectedColumns.findIndex((c) => c.key === active.id)\n const newIndex = selectedColumns.findIndex((c) => c.key === over.id)\n if (oldIndex === -1 || newIndex === -1) return\n const reordered = [...selectedColumns]\n const [moved] = reordered.splice(oldIndex, 1)\n reordered.splice(newIndex, 0, moved)\n onReorderColumns(reordered.map((c) => c.key))\n }, [selectedColumns, onReorderColumns])\n\n const handleHideAll = React.useCallback(() => {\n for (const col of selectedColumns) {\n if (!col.alwaysVisible) onToggleColumn(col.key)\n }\n }, [selectedColumns, onToggleColumn])\n\n return (\n <div className=\"flex flex-col\">\n <div className=\"px-4 py-3 border-t\">\n <SearchInput\n size=\"sm\"\n placeholder={t('ui.columnChooser.search', 'Search columns...')}\n value={searchQuery}\n onChange={setSearchQuery}\n />\n </div>\n\n <div>\n {selectedColumns.length > 0 ? (\n <div className=\"px-4 py-3\">\n <div className=\"flex items-center justify-between mb-3\">\n <div className=\"flex items-baseline gap-2\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground\">\n {t('ui.columnChooser.shown', 'Shown')}\n </div>\n <span className=\"text-xs text-muted-foreground\">\n {selectedColumns.length}/{availableColumns.length}\n </span>\n </div>\n <Button\n type=\"button\"\n variant=\"link\"\n size=\"sm\"\n className=\"h-auto px-0 text-sm text-muted-foreground hover:text-foreground\"\n onClick={handleHideAll}\n >\n {t('ui.columnChooser.hideAll', 'Hide all')}\n </Button>\n </div>\n <DndContext id={dndContextId} sensors={sensors} collisionDetection={closestCenter} onDragEnd={handleDragEnd}>\n <SortableContext items={selectedColumns.map((c) => c.key)} strategy={verticalListSortingStrategy}>\n <div className=\"space-y-2\">\n {selectedColumns.map((col) => (\n <SortableColumnItem key={col.key} column={col} onToggle={onToggleColumn} />\n ))}\n </div>\n </SortableContext>\n </DndContext>\n </div>\n ) : null}\n\n <div className=\"border-t px-4 py-3 mt-4 mb-4\">\n <div className=\"text-xs font-medium uppercase tracking-wide text-muted-foreground mb-3\">\n {t('ui.columnChooser.available', 'Available columns')}\n </div>\n {Array.from(groupedAvailable.entries()).map(([group, columns]) => {\n const isCollapsed = !searchQuery && !expandedGroups.has(group)\n return (\n <div key={group} className=\"mb-2\">\n <Button\n type=\"button\"\n variant=\"ghost\"\n size=\"sm\"\n className=\"w-full justify-start gap-2 h-auto px-1 py-2 text-xs font-medium uppercase text-muted-foreground\"\n onClick={() => toggleGroup(group)}\n >\n <ChevronRight className={`size-4 transition-transform ${isCollapsed ? '' : 'rotate-90'}`} />\n <span>{resolveGroupLabel(t, group, 'ui.columnChooser.ungrouped', 'Other')}</span>\n </Button>\n {!isCollapsed ? (\n <div className=\"space-y-2 mt-2\">\n {columns.map((col) => (\n <div\n key={col.key}\n className=\"flex items-center gap-2 rounded pl-7 pr-2 py-1.5 text-sm hover:bg-muted/50 cursor-pointer\"\n onClick={() => { if (!col.alwaysVisible) onToggleColumn(col.key) }}\n >\n <span className=\"truncate flex-1 min-w-0\">{col.label}</span>\n <Switch\n checked={false}\n disabled={col.alwaysVisible}\n onCheckedChange={() => onToggleColumn(col.key)}\n onClick={(e) => e.stopPropagation()}\n className=\"shrink-0 scale-90\"\n />\n </div>\n ))}\n </div>\n ) : null}\n </div>\n )\n })}\n </div>\n </div>\n </div>\n )\n}\n\nexport function ColumnChooserPanel({\n open,\n onOpenChange,\n ...sectionProps\n}: ColumnChooserPanelProps) {\n const t = useT()\n React.useEffect(() => {\n if (!open) return\n if (typeof document === 'undefined') return\n document.body.dataset.columnChooserOpen = 'true'\n return () => {\n delete document.body.dataset.columnChooserOpen\n }\n }, [open])\n if (!open) return null\n return (\n <div className=\"fixed inset-y-0 right-0 z-modal w-80 border-l bg-background shadow-lg flex flex-col\">\n <div className=\"flex items-center justify-between border-b px-4 py-3\">\n <h3 className=\"font-semibold text-sm\">\n {t('ui.columnChooser.title', 'Columns')}\n </h3>\n <IconButton variant=\"ghost\" size=\"sm\" type=\"button\" onClick={() => onOpenChange(false)} aria-label={t('ui.columnChooser.close', 'Close')}>\n <X className=\"size-4\" />\n </IconButton>\n </div>\n <div className=\"flex-1 overflow-auto\">\n <ColumnChooserSection {...sectionProps} />\n </div>\n </div>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { insertByInjectionPlacement } from "@open-mercato/shared/modules/widgets/injection-position";
|
|
2
|
+
const INJECTED_FIELDS_FALLBACK_GROUP_ID = "__injected-fields__";
|
|
3
|
+
function hostsPlainFields(group) {
|
|
4
|
+
return group.kind !== "customFields" && !group.bare;
|
|
5
|
+
}
|
|
6
|
+
function entryId(entry) {
|
|
7
|
+
return typeof entry === "string" ? entry : entry.id;
|
|
8
|
+
}
|
|
9
|
+
function placeInjectedFieldsInGroups(groups, definitions, placementByFieldId, onFallback) {
|
|
10
|
+
const placed = groups.map((group) => ({ ...group, fields: [...group.fields ?? []] }));
|
|
11
|
+
let fallbackIndex = -1;
|
|
12
|
+
for (let index = placed.length - 1; index >= 0; index -= 1) {
|
|
13
|
+
if (hostsPlainFields(placed[index])) {
|
|
14
|
+
fallbackIndex = index;
|
|
15
|
+
break;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (const definition of definitions) {
|
|
19
|
+
let index = definition.group ? placed.findIndex((group) => group.id === definition.group) : -1;
|
|
20
|
+
if (index < 0) {
|
|
21
|
+
const placement = placementByFieldId.get(definition.id);
|
|
22
|
+
if (definition.group && placement?.kind === "group") {
|
|
23
|
+
placed.push({
|
|
24
|
+
id: definition.group,
|
|
25
|
+
title: placement.groupLabel,
|
|
26
|
+
description: placement.groupDescription,
|
|
27
|
+
column: placement.column === 2 ? 2 : 1,
|
|
28
|
+
fields: []
|
|
29
|
+
});
|
|
30
|
+
index = placed.length - 1;
|
|
31
|
+
} else {
|
|
32
|
+
onFallback?.(definition);
|
|
33
|
+
if (fallbackIndex < 0) {
|
|
34
|
+
placed.push({ id: INJECTED_FIELDS_FALLBACK_GROUP_ID, fields: [] });
|
|
35
|
+
fallbackIndex = placed.length - 1;
|
|
36
|
+
}
|
|
37
|
+
index = fallbackIndex;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
const fieldEntries = placed[index].fields ?? [];
|
|
41
|
+
if (fieldEntries.some((entry) => entryId(entry) === definition.id)) continue;
|
|
42
|
+
placed[index].fields = insertByInjectionPlacement(fieldEntries, definition.id, definition.placement, entryId);
|
|
43
|
+
}
|
|
44
|
+
return placed;
|
|
45
|
+
}
|
|
46
|
+
export {
|
|
47
|
+
INJECTED_FIELDS_FALLBACK_GROUP_ID,
|
|
48
|
+
placeInjectedFieldsInGroups
|
|
49
|
+
};
|
|
50
|
+
//# sourceMappingURL=injectedFieldGroups.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/crud/injectedFieldGroups.ts"],
|
|
4
|
+
"sourcesContent": ["import type { InjectionFieldDefinition } from '@open-mercato/shared/modules/widgets/injection'\nimport { insertByInjectionPlacement } from '@open-mercato/shared/modules/widgets/injection-position'\nimport type { CrudFormGroup } from '../CrudForm'\n\nexport type InjectedFieldWidgetPlacement = {\n kind?: 'tab' | 'group' | 'stack'\n groupLabel?: string\n groupDescription?: string\n column?: 1 | 2\n}\n\nexport const INJECTED_FIELDS_FALLBACK_GROUP_ID = '__injected-fields__'\n\nfunction hostsPlainFields(group: CrudFormGroup): boolean {\n return group.kind !== 'customFields' && !group.bare\n}\n\nfunction entryId(entry: NonNullable<CrudFormGroup['fields']>[number]): string {\n return typeof entry === 'string' ? entry : entry.id\n}\n\n/**\n * Places injected field definitions into the host form's groups.\n *\n * - A field whose `group` matches a declared group is inserted there (honoring `placement`).\n * - A field whose `group` is not declared, injected by a widget mapped with `kind: 'group'`,\n * gets its own card built from the injection-table `groupLabel`/`groupDescription`/`column`.\n * - Any other field falls back to the last group that renders plain fields \u2014 never into a\n * `customFields` or `bare` group, which do not render `group.fields` and would drop it.\n * A field that explicitly names a `customFields` or `bare` group is still placed there and,\n * like any non-field entry of such a group, is not rendered.\n */\nexport function placeInjectedFieldsInGroups(\n groups: CrudFormGroup[],\n definitions: InjectionFieldDefinition[],\n placementByFieldId: ReadonlyMap<string, InjectedFieldWidgetPlacement | undefined>,\n onFallback?: (definition: InjectionFieldDefinition) => void,\n): CrudFormGroup[] {\n const placed: CrudFormGroup[] = groups.map((group) => ({ ...group, fields: [...(group.fields ?? [])] }))\n let fallbackIndex = -1\n for (let index = placed.length - 1; index >= 0; index -= 1) {\n if (hostsPlainFields(placed[index])) {\n fallbackIndex = index\n break\n }\n }\n\n for (const definition of definitions) {\n let index = definition.group ? placed.findIndex((group) => group.id === definition.group) : -1\n if (index < 0) {\n const placement = placementByFieldId.get(definition.id)\n if (definition.group && placement?.kind === 'group') {\n placed.push({\n id: definition.group,\n title: placement.groupLabel,\n description: placement.groupDescription,\n column: placement.column === 2 ? 2 : 1,\n fields: [],\n })\n index = placed.length - 1\n } else {\n onFallback?.(definition)\n if (fallbackIndex < 0) {\n placed.push({ id: INJECTED_FIELDS_FALLBACK_GROUP_ID, fields: [] })\n fallbackIndex = placed.length - 1\n }\n index = fallbackIndex\n }\n }\n const fieldEntries = placed[index].fields ?? []\n if (fieldEntries.some((entry) => entryId(entry) === definition.id)) continue\n placed[index].fields = insertByInjectionPlacement(fieldEntries, definition.id, definition.placement, entryId)\n }\n return placed\n}\n"],
|
|
5
|
+
"mappings": "AACA,SAAS,kCAAkC;AAUpC,MAAM,oCAAoC;AAEjD,SAAS,iBAAiB,OAA+B;AACvD,SAAO,MAAM,SAAS,kBAAkB,CAAC,MAAM;AACjD;AAEA,SAAS,QAAQ,OAA6D;AAC5E,SAAO,OAAO,UAAU,WAAW,QAAQ,MAAM;AACnD;AAaO,SAAS,4BACd,QACA,aACA,oBACA,YACiB;AACjB,QAAM,SAA0B,OAAO,IAAI,CAAC,WAAW,EAAE,GAAG,OAAO,QAAQ,CAAC,GAAI,MAAM,UAAU,CAAC,CAAE,EAAE,EAAE;AACvG,MAAI,gBAAgB;AACpB,WAAS,QAAQ,OAAO,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG;AAC1D,QAAI,iBAAiB,OAAO,KAAK,CAAC,GAAG;AACnC,sBAAgB;AAChB;AAAA,IACF;AAAA,EACF;AAEA,aAAW,cAAc,aAAa;AACpC,QAAI,QAAQ,WAAW,QAAQ,OAAO,UAAU,CAAC,UAAU,MAAM,OAAO,WAAW,KAAK,IAAI;AAC5F,QAAI,QAAQ,GAAG;AACb,YAAM,YAAY,mBAAmB,IAAI,WAAW,EAAE;AACtD,UAAI,WAAW,SAAS,WAAW,SAAS,SAAS;AACnD,eAAO,KAAK;AAAA,UACV,IAAI,WAAW;AAAA,UACf,OAAO,UAAU;AAAA,UACjB,aAAa,UAAU;AAAA,UACvB,QAAQ,UAAU,WAAW,IAAI,IAAI;AAAA,UACrC,QAAQ,CAAC;AAAA,QACX,CAAC;AACD,gBAAQ,OAAO,SAAS;AAAA,MAC1B,OAAO;AACL,qBAAa,UAAU;AACvB,YAAI,gBAAgB,GAAG;AACrB,iBAAO,KAAK,EAAE,IAAI,mCAAmC,QAAQ,CAAC,EAAE,CAAC;AACjE,0BAAgB,OAAO,SAAS;AAAA,QAClC;AACA,gBAAQ;AAAA,MACV;AAAA,IACF;AACA,UAAM,eAAe,OAAO,KAAK,EAAE,UAAU,CAAC;AAC9C,QAAI,aAAa,KAAK,CAAC,UAAU,QAAQ,KAAK,MAAM,WAAW,EAAE,EAAG;AACpE,WAAO,KAAK,EAAE,SAAS,2BAA2B,cAAc,WAAW,IAAI,WAAW,WAAW,OAAO;AAAA,EAC9G;AACA,SAAO;AACT;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -4,6 +4,7 @@ import * as React from "react";
|
|
|
4
4
|
import { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter } from "lucide-react";
|
|
5
5
|
import { Popover, PopoverContent, PopoverAnchor } from "../../primitives/popover.js";
|
|
6
6
|
import { useT } from "@open-mercato/shared/lib/i18n/context";
|
|
7
|
+
import { UNGROUPED_GROUP_ID, resolveGroupLabel } from "../utils/groupLabels.js";
|
|
7
8
|
const TYPE_ICON = {
|
|
8
9
|
text: ALargeSmall,
|
|
9
10
|
select: Activity,
|
|
@@ -44,7 +45,7 @@ function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef })
|
|
|
44
45
|
const map = /* @__PURE__ */ new Map();
|
|
45
46
|
const order = [];
|
|
46
47
|
for (const f of filtered) {
|
|
47
|
-
const g = f.group ??
|
|
48
|
+
const g = f.group ?? UNGROUPED_GROUP_ID;
|
|
48
49
|
if (!map.has(g)) {
|
|
49
50
|
map.set(g, []);
|
|
50
51
|
order.push(g);
|
|
@@ -107,7 +108,7 @@ function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef })
|
|
|
107
108
|
)
|
|
108
109
|
] }),
|
|
109
110
|
/* @__PURE__ */ jsx("div", { className: "max-h-[400px] overflow-y-auto py-1", role: "listbox", children: grouped.map(({ group, items }) => /* @__PURE__ */ jsxs("div", { children: [
|
|
110
|
-
/* @__PURE__ */ jsx("div", { className: "px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground", children: group }),
|
|
111
|
+
/* @__PURE__ */ jsx("div", { className: "px-3 pt-2 pb-1 text-overline font-semibold uppercase tracking-widest text-muted-foreground", children: resolveGroupLabel(t, group, "ui.advancedFilter.fieldPicker.ungrouped", "More") }),
|
|
111
112
|
items.map((f) => {
|
|
112
113
|
const flatIdx = flatVisible.indexOf(f);
|
|
113
114
|
const Icon = resolveIcon(f);
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/filters/FilterFieldPicker.tsx"],
|
|
4
|
-
"sourcesContent": ["// packages/ui/src/backend/filters/FilterFieldPicker.tsx\n'use client'\nimport * as React from 'react'\nimport { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'\nimport { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'\n\nconst TYPE_ICON: Record<FilterFieldType, LucideIcon> = {\n text: ALargeSmall,\n select: Activity,\n tags: Tag,\n date: Calendar,\n number: Hash,\n boolean: ArrowRight,\n}\n\nconst NAMED_ICONS: Record<string, LucideIcon> = {\n 'user-round': UserRound,\n mail: Mail,\n phone: Phone,\n tag: Tag,\n filter: Filter,\n}\n\nfunction resolveIcon(field: FilterFieldDef): LucideIcon {\n if (field.iconName && NAMED_ICONS[field.iconName]) return NAMED_ICONS[field.iconName]\n return TYPE_ICON[field.type] ?? ALargeSmall\n}\n\nexport type FilterFieldPickerProps = {\n fields: FilterFieldDef[]\n open: boolean\n onOpenChange: (open: boolean) => void\n onSelect: (field: FilterFieldDef) => void\n triggerRef: React.RefObject<HTMLElement | null>\n}\n\nexport function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef }: FilterFieldPickerProps) {\n const t = useT()\n const [query, setQuery] = React.useState('')\n const [activeIdx, setActiveIdx] = React.useState(0)\n const searchRef = React.useRef<HTMLInputElement>(null)\n\n React.useEffect(() => { if (open) { setQuery(''); setActiveIdx(0); searchRef.current?.focus() } }, [open])\n\n const filtered = React.useMemo(() => {\n if (!query.trim()) return fields\n const q = query.trim().toLowerCase()\n return fields.filter(f => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q))\n }, [fields, query])\n\n const grouped = React.useMemo(() => {\n const map = new Map<string, FilterFieldDef[]>()\n const order: string[] = []\n for (const f of filtered) {\n const g = f.group ??
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["// packages/ui/src/backend/filters/FilterFieldPicker.tsx\n'use client'\nimport * as React from 'react'\nimport { Search, Activity, Calendar, Hash, Tag, ArrowRight, ALargeSmall, UserRound, Mail, Phone, Filter, type LucideIcon } from 'lucide-react'\nimport { Popover, PopoverContent, PopoverAnchor } from '../../primitives/popover'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport { UNGROUPED_GROUP_ID, resolveGroupLabel } from '../utils/groupLabels'\nimport type { FilterFieldDef, FilterFieldType } from '@open-mercato/shared/lib/query/advanced-filter'\n\nconst TYPE_ICON: Record<FilterFieldType, LucideIcon> = {\n text: ALargeSmall,\n select: Activity,\n tags: Tag,\n date: Calendar,\n number: Hash,\n boolean: ArrowRight,\n}\n\nconst NAMED_ICONS: Record<string, LucideIcon> = {\n 'user-round': UserRound,\n mail: Mail,\n phone: Phone,\n tag: Tag,\n filter: Filter,\n}\n\nfunction resolveIcon(field: FilterFieldDef): LucideIcon {\n if (field.iconName && NAMED_ICONS[field.iconName]) return NAMED_ICONS[field.iconName]\n return TYPE_ICON[field.type] ?? ALargeSmall\n}\n\nexport type FilterFieldPickerProps = {\n fields: FilterFieldDef[]\n open: boolean\n onOpenChange: (open: boolean) => void\n onSelect: (field: FilterFieldDef) => void\n triggerRef: React.RefObject<HTMLElement | null>\n}\n\nexport function FilterFieldPicker({ fields, open, onOpenChange, onSelect, triggerRef }: FilterFieldPickerProps) {\n const t = useT()\n const [query, setQuery] = React.useState('')\n const [activeIdx, setActiveIdx] = React.useState(0)\n const searchRef = React.useRef<HTMLInputElement>(null)\n\n React.useEffect(() => { if (open) { setQuery(''); setActiveIdx(0); searchRef.current?.focus() } }, [open])\n\n const filtered = React.useMemo(() => {\n if (!query.trim()) return fields\n const q = query.trim().toLowerCase()\n return fields.filter(f => f.label.toLowerCase().includes(q) || f.key.toLowerCase().includes(q))\n }, [fields, query])\n\n const grouped = React.useMemo(() => {\n const map = new Map<string, FilterFieldDef[]>()\n const order: string[] = []\n for (const f of filtered) {\n const g = f.group ?? UNGROUPED_GROUP_ID\n if (!map.has(g)) { map.set(g, []); order.push(g) }\n map.get(g)!.push(f)\n }\n return order.map(g => ({ group: g, items: map.get(g)! }))\n }, [filtered])\n\n const flatVisible = React.useMemo(() => grouped.flatMap(g => g.items), [grouped])\n\n React.useEffect(() => {\n if (!open) return\n function onKey(e: KeyboardEvent) {\n if (e.key === 'Escape') { onOpenChange(false); return }\n if (e.key === 'ArrowDown') { e.preventDefault(); setActiveIdx(i => Math.min(i + 1, flatVisible.length - 1)) }\n if (e.key === 'ArrowUp') { e.preventDefault(); setActiveIdx(i => Math.max(i - 1, 0)) }\n if (e.key === 'Enter') {\n e.preventDefault()\n const f = flatVisible[activeIdx]\n if (f) { onSelect(f); onOpenChange(false) }\n }\n if (e.key === '/' && document.activeElement !== searchRef.current) {\n e.preventDefault(); searchRef.current?.focus()\n }\n }\n window.addEventListener('keydown', onKey)\n return () => window.removeEventListener('keydown', onKey)\n }, [open, flatVisible, activeIdx, onSelect, onOpenChange])\n\n return (\n <Popover open={open} onOpenChange={onOpenChange}>\n {/* Radix expects RefObject<Measurable>; our prop accepts null. Narrow cast covers the React 19 \u2194 Radix variance. */}\n <PopoverAnchor virtualRef={triggerRef as React.RefObject<{ getBoundingClientRect(): DOMRect }>} />\n <PopoverContent className=\"w-80 p-0\" align=\"start\" data-advanced-filter-portal=\"\">\n <div className=\"flex flex-col\">\n <div className=\"relative p-2 border-b border-border\">\n <Search className=\"absolute left-4 top-1/2 size-4 -translate-y-1/2 text-muted-foreground\" />\n <input\n ref={searchRef}\n type=\"text\"\n value={query}\n onChange={(e) => { setQuery(e.target.value); setActiveIdx(0) }}\n placeholder={t('ui.advancedFilter.fieldPicker.search', 'Search field\u2026')}\n className=\"h-8 w-full rounded-md border border-input bg-background pl-8 pr-2 text-sm outline-none focus-visible:border-ring focus-visible:ring-2 focus-visible:ring-ring/50\"\n aria-label={t('ui.advancedFilter.fieldPicker.search', 'Search field\u2026')}\n />\n </div>\n <div className=\"max-h-[400px] overflow-y-auto py-1\" role=\"listbox\">\n {grouped.map(({ group, items }) => (\n <div key={group}>\n <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>\n {items.map((f) => {\n const flatIdx = flatVisible.indexOf(f)\n const Icon = resolveIcon(f)\n const active = flatIdx === activeIdx\n return (\n // Raw <button> required: needs role=\"option\" inside the listbox, which <Button>\n // would override (Button forces role=\"button\"). a11y semantics take precedence\n // over the primitive contract for listbox items.\n <button\n key={f.key}\n type=\"button\"\n role=\"option\"\n aria-selected={active}\n onClick={() => { onSelect(f); onOpenChange(false) }}\n onMouseEnter={() => setActiveIdx(flatIdx)}\n className={`flex w-full items-center gap-2 px-3 py-1.5 text-left text-sm hover:bg-accent ${active ? 'bg-secondary' : ''}`}\n >\n <Icon className=\"size-4 text-muted-foreground\" />\n <span>{f.label}</span>\n </button>\n )\n })}\n </div>\n ))}\n </div>\n <div className=\"px-3 py-2 border-t border-border text-xs text-muted-foreground\">\n {t('ui.advancedFilter.fieldPicker.hint', 'Tip: Type to search, \u2191\u2193 to navigate, Enter to select')}\n </div>\n </div>\n </PopoverContent>\n </Popover>\n )\n}\n"],
|
|
5
|
+
"mappings": ";AAwFM,cAGI,YAHJ;AAtFN,YAAY,WAAW;AACvB,SAAS,QAAQ,UAAU,UAAU,MAAM,KAAK,YAAY,aAAa,WAAW,MAAM,OAAO,cAA+B;AAChI,SAAS,SAAS,gBAAgB,qBAAqB;AACvD,SAAS,YAAY;AACrB,SAAS,oBAAoB,yBAAyB;AAGtD,MAAM,YAAiD;AAAA,EACrD,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,MAAM;AAAA,EACN,MAAM;AAAA,EACN,QAAQ;AAAA,EACR,SAAS;AACX;AAEA,MAAM,cAA0C;AAAA,EAC9C,cAAc;AAAA,EACd,MAAM;AAAA,EACN,OAAO;AAAA,EACP,KAAK;AAAA,EACL,QAAQ;AACV;AAEA,SAAS,YAAY,OAAmC;AACtD,MAAI,MAAM,YAAY,YAAY,MAAM,QAAQ,EAAG,QAAO,YAAY,MAAM,QAAQ;AACpF,SAAO,UAAU,MAAM,IAAI,KAAK;AAClC;AAUO,SAAS,kBAAkB,EAAE,QAAQ,MAAM,cAAc,UAAU,WAAW,GAA2B;AAC9G,QAAM,IAAI,KAAK;AACf,QAAM,CAAC,OAAO,QAAQ,IAAI,MAAM,SAAS,EAAE;AAC3C,QAAM,CAAC,WAAW,YAAY,IAAI,MAAM,SAAS,CAAC;AAClD,QAAM,YAAY,MAAM,OAAyB,IAAI;AAErD,QAAM,UAAU,MAAM;AAAE,QAAI,MAAM;AAAE,eAAS,EAAE;AAAG,mBAAa,CAAC;AAAG,gBAAU,SAAS,MAAM;AAAA,IAAE;AAAA,EAAE,GAAG,CAAC,IAAI,CAAC;AAEzG,QAAM,WAAW,MAAM,QAAQ,MAAM;AACnC,QAAI,CAAC,MAAM,KAAK,EAAG,QAAO;AAC1B,UAAM,IAAI,MAAM,KAAK,EAAE,YAAY;AACnC,WAAO,OAAO,OAAO,OAAK,EAAE,MAAM,YAAY,EAAE,SAAS,CAAC,KAAK,EAAE,IAAI,YAAY,EAAE,SAAS,CAAC,CAAC;AAAA,EAChG,GAAG,CAAC,QAAQ,KAAK,CAAC;AAElB,QAAM,UAAU,MAAM,QAAQ,MAAM;AAClC,UAAM,MAAM,oBAAI,IAA8B;AAC9C,UAAM,QAAkB,CAAC;AACzB,eAAW,KAAK,UAAU;AACxB,YAAM,IAAI,EAAE,SAAS;AACrB,UAAI,CAAC,IAAI,IAAI,CAAC,GAAG;AAAE,YAAI,IAAI,GAAG,CAAC,CAAC;AAAG,cAAM,KAAK,CAAC;AAAA,MAAE;AACjD,UAAI,IAAI,CAAC,EAAG,KAAK,CAAC;AAAA,IACpB;AACA,WAAO,MAAM,IAAI,QAAM,EAAE,OAAO,GAAG,OAAO,IAAI,IAAI,CAAC,EAAG,EAAE;AAAA,EAC1D,GAAG,CAAC,QAAQ,CAAC;AAEb,QAAM,cAAc,MAAM,QAAQ,MAAM,QAAQ,QAAQ,OAAK,EAAE,KAAK,GAAG,CAAC,OAAO,CAAC;AAEhF,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,KAAM;AACX,aAAS,MAAM,GAAkB;AAC/B,UAAI,EAAE,QAAQ,UAAU;AAAE,qBAAa,KAAK;AAAG;AAAA,MAAO;AACtD,UAAI,EAAE,QAAQ,aAAa;AAAE,UAAE,eAAe;AAAG,qBAAa,OAAK,KAAK,IAAI,IAAI,GAAG,YAAY,SAAS,CAAC,CAAC;AAAA,MAAE;AAC5G,UAAI,EAAE,QAAQ,WAAa;AAAE,UAAE,eAAe;AAAG,qBAAa,OAAK,KAAK,IAAI,IAAI,GAAG,CAAC,CAAC;AAAA,MAAE;AACvF,UAAI,EAAE,QAAQ,SAAS;AACrB,UAAE,eAAe;AACjB,cAAM,IAAI,YAAY,SAAS;AAC/B,YAAI,GAAG;AAAE,mBAAS,CAAC;AAAG,uBAAa,KAAK;AAAA,QAAE;AAAA,MAC5C;AACA,UAAI,EAAE,QAAQ,OAAO,SAAS,kBAAkB,UAAU,SAAS;AACjE,UAAE,eAAe;AAAG,kBAAU,SAAS,MAAM;AAAA,MAC/C;AAAA,IACF;AACA,WAAO,iBAAiB,WAAW,KAAK;AACxC,WAAO,MAAM,OAAO,oBAAoB,WAAW,KAAK;AAAA,EAC1D,GAAG,CAAC,MAAM,aAAa,WAAW,UAAU,YAAY,CAAC;AAEzD,SACE,qBAAC,WAAQ,MAAY,cAEnB;AAAA,wBAAC,iBAAc,YAAY,YAAqE;AAAA,IAChG,oBAAC,kBAAe,WAAU,YAAW,OAAM,SAAQ,+BAA4B,IAC7E,+BAAC,SAAI,WAAU,iBACb;AAAA,2BAAC,SAAI,WAAU,uCACb;AAAA,4BAAC,UAAO,WAAU,yEAAwE;AAAA,QAC1F;AAAA,UAAC;AAAA;AAAA,YACC,KAAK;AAAA,YACL,MAAK;AAAA,YACL,OAAO;AAAA,YACP,UAAU,CAAC,MAAM;AAAE,uBAAS,EAAE,OAAO,KAAK;AAAG,2BAAa,CAAC;AAAA,YAAE;AAAA,YAC7D,aAAa,EAAE,wCAAwC,oBAAe;AAAA,YACtE,WAAU;AAAA,YACV,cAAY,EAAE,wCAAwC,oBAAe;AAAA;AAAA,QACvE;AAAA,SACF;AAAA,MACA,oBAAC,SAAI,WAAU,sCAAqC,MAAK,WACtD,kBAAQ,IAAI,CAAC,EAAE,OAAO,MAAM,MAC3B,qBAAC,SACC;AAAA,4BAAC,SAAI,WAAU,8FAA8F,4BAAkB,GAAG,OAAO,2CAA2C,MAAM,GAAE;AAAA,QAC3L,MAAM,IAAI,CAAC,MAAM;AAChB,gBAAM,UAAU,YAAY,QAAQ,CAAC;AACrC,gBAAM,OAAO,YAAY,CAAC;AAC1B,gBAAM,SAAS,YAAY;AAC3B;AAAA;AAAA;AAAA;AAAA,YAIE;AAAA,cAAC;AAAA;AAAA,gBAEC,MAAK;AAAA,gBACL,MAAK;AAAA,gBACL,iBAAe;AAAA,gBACf,SAAS,MAAM;AAAE,2BAAS,CAAC;AAAG,+BAAa,KAAK;AAAA,gBAAE;AAAA,gBAClD,cAAc,MAAM,aAAa,OAAO;AAAA,gBACxC,WAAW,gFAAgF,SAAS,iBAAiB,EAAE;AAAA,gBAEvH;AAAA,sCAAC,QAAK,WAAU,gCAA+B;AAAA,kBAC/C,oBAAC,UAAM,YAAE,OAAM;AAAA;AAAA;AAAA,cATV,EAAE;AAAA,YAUT;AAAA;AAAA,QAEJ,CAAC;AAAA,WAvBO,KAwBV,CACD,GACH;AAAA,MACA,oBAAC,SAAI,WAAU,kEACZ,YAAE,sCAAsC,gEAAsD,GACjG;AAAA,OACF,GACF;AAAA,KACF;AAEJ;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -6,7 +6,19 @@ import { pushOperation } from "../operations/store.js";
|
|
|
6
6
|
import { pushPartialIndexWarning } from "../indexes/store.js";
|
|
7
7
|
import { createScopedHeaderStack } from "./scopedHeaderStack.js";
|
|
8
8
|
import { createLogger } from "@open-mercato/shared/lib/logger";
|
|
9
|
+
import { translateWithFallback } from "@open-mercato/shared/lib/i18n/translate";
|
|
9
10
|
const logger = createLogger("ui").child({ component: "apiFetch" });
|
|
11
|
+
const fallbackAuthNoticeTranslator = (key) => key;
|
|
12
|
+
let authNoticeTranslator = null;
|
|
13
|
+
function registerAuthNoticeTranslator(translate) {
|
|
14
|
+
authNoticeTranslator = translate;
|
|
15
|
+
return () => {
|
|
16
|
+
if (authNoticeTranslator === translate) authNoticeTranslator = null;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function translateAuthNotice(key, fallback, params) {
|
|
20
|
+
return translateWithFallback(authNoticeTranslator ?? fallbackAuthNoticeTranslator, key, fallback, params);
|
|
21
|
+
}
|
|
10
22
|
const scopedHeaders = createScopedHeaderStack();
|
|
11
23
|
function mergeHeaders(base, scoped) {
|
|
12
24
|
const headers = new Headers(base ?? {});
|
|
@@ -80,7 +92,7 @@ function redirectToSessionRefresh() {
|
|
|
80
92
|
if (recentlyAttemptedSessionRefresh(current)) return;
|
|
81
93
|
recordSessionRefreshAttempt(current);
|
|
82
94
|
try {
|
|
83
|
-
flash("Session expired. Redirecting to sign in\u2026", "warning");
|
|
95
|
+
flash(translateAuthNotice("ui.auth.sessionExpired", "Session expired. Redirecting to sign in\u2026"), "warning");
|
|
84
96
|
setTimeout(() => {
|
|
85
97
|
window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`;
|
|
86
98
|
}, 20);
|
|
@@ -107,12 +119,20 @@ function formatForbiddenAccessMessage(options) {
|
|
|
107
119
|
const roles = options?.requiredRoles?.filter(Boolean) ?? [];
|
|
108
120
|
const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean);
|
|
109
121
|
if (features.length) {
|
|
110
|
-
return
|
|
122
|
+
return translateAuthNotice(
|
|
123
|
+
"ui.auth.accessDenied.missingFeatures",
|
|
124
|
+
'Access denied: you are missing the required permission "{features}". Contact your administrator.',
|
|
125
|
+
{ features: features.join(", ") }
|
|
126
|
+
);
|
|
111
127
|
}
|
|
112
128
|
if (effectiveRoles.length) {
|
|
113
|
-
return
|
|
129
|
+
return translateAuthNotice(
|
|
130
|
+
"ui.auth.accessDenied.requiredRoles",
|
|
131
|
+
'Access denied: this area requires the role "{roles}". Contact your administrator.',
|
|
132
|
+
{ roles: effectiveRoles.join(", ") }
|
|
133
|
+
);
|
|
114
134
|
}
|
|
115
|
-
return "Access denied: you do not have permission to perform this action.";
|
|
135
|
+
return translateAuthNotice("ui.auth.accessDenied.generic", "Access denied: you do not have permission to perform this action.");
|
|
116
136
|
}
|
|
117
137
|
function notifyForbiddenAccess(options) {
|
|
118
138
|
if (typeof window === "undefined") return;
|
|
@@ -226,6 +246,7 @@ export {
|
|
|
226
246
|
notifyForbiddenAccess,
|
|
227
247
|
redirectToForbiddenLogin,
|
|
228
248
|
redirectToSessionRefresh,
|
|
249
|
+
registerAuthNoticeTranslator,
|
|
229
250
|
setAuthRedirectConfig,
|
|
230
251
|
withScopedApiHeaders
|
|
231
252
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/api.ts"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n// Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)\n// Used across UI data utilities to avoid duplication.\nimport { flash } from '../FlashMessages'\nimport { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\n\nconst logger = createLogger('ui').child({ component: 'apiFetch' })\n\nconst scopedHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {\n const headers = new Headers(base ?? {})\n for (const [key, value] of Object.entries(scoped)) {\n if (headers.has(key)) continue\n headers.set(key, value)\n }\n return headers\n}\n\nfunction readRedirectOverride(headers: Headers, headerName: string): boolean {\n return headers.get(headerName) === '0'\n}\n\nfunction isSameOriginRequest(input: RequestInfo | URL): boolean {\n if (typeof window === 'undefined') return false\n const host = window.location?.host\n if (!host) return false\n let urlString: string\n if (typeof input === 'string') urlString = input\n else if (input instanceof URL) urlString = input.toString()\n else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url\n else return false\n if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true\n try {\n return new URL(urlString).host === host\n } catch {\n return false\n }\n}\n\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\n}\n\nfunction readPathname(): string {\n return typeof window !== 'undefined' ? window.location?.pathname ?? '' : ''\n}\n\n// `/start` is the anonymous landing/dashboard page and, like `/login`, runs a\n// background auth-probe whose 401 is expected for a logged-out visitor \u2014 not a\n// real session problem worth surfacing (see GH #6159).\nfunction isLoginPathname(pathname: string): boolean {\n return pathname.startsWith('/login') || pathname.startsWith('/start')\n}\n\nfunction isPortalPathname(pathname: string): boolean {\n return /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\n}\n\nexport class UnauthorizedError extends Error {\n readonly status = 401\n constructor(message = 'Unauthorized') {\n super(message)\n this.name = 'UnauthorizedError'\n }\n}\n\nconst SESSION_REFRESH_ATTEMPT_KEY_PREFIX = 'om:session-refresh-attempt:'\nconst SESSION_REFRESH_COOLDOWN_MS = 10_000\n\n// A genuinely expired session redirects once per target, then goes quiet: the\n// refresh always succeeds (the session cookie is fine), bounces back to the\n// same URL, and re-triggers the same 401 for any non-session cause \u2014 without\n// this guard that bounce repeats forever (GH #5186).\nfunction recentlyAttemptedSessionRefresh(target: string): boolean {\n try {\n const raw = window.sessionStorage.getItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target)\n if (!raw) return false\n const attemptedAt = Number(raw)\n return Number.isFinite(attemptedAt) && Date.now() - attemptedAt < SESSION_REFRESH_COOLDOWN_MS\n } catch {\n return false\n }\n}\n\nfunction recordSessionRefreshAttempt(target: string): void {\n try {\n window.sessionStorage.setItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target, String(Date.now()))\n } catch {\n // no-op\n }\n}\n\nexport function redirectToSessionRefresh() {\n if (typeof window === 'undefined') return\n const current = window.location.pathname + window.location.search\n // Avoid redirect loops if already on an auth/session route\n if (window.location.pathname.startsWith('/api/auth')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n if (recentlyAttemptedSessionRefresh(current)) return\n recordSessionRefreshAttempt(current)\n try {\n flash('Session expired. Redirecting to sign in\u2026', 'warning')\n setTimeout(() => {\n window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`\n }, 20)\n } catch {\n // no-op\n }\n}\n\nexport class ForbiddenError extends Error {\n readonly status = 403\n readonly requiredFeatures: string[] | null\n readonly requiredRoles: string[] | null\n\n constructor(\n message = 'Forbidden',\n options?: { requiredFeatures?: string[] | null; requiredRoles?: string[] | null },\n ) {\n super(message)\n this.name = 'ForbiddenError'\n this.requiredFeatures = options?.requiredFeatures?.length ? [...options.requiredFeatures] : null\n this.requiredRoles = options?.requiredRoles?.length ? [...options.requiredRoles] : null\n }\n}\n\nlet DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']\n\nexport function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {\n if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {\n DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)\n }\n}\n\nfunction formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }): string {\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n if (features.length) {\n return `Access denied: you are missing the required permission \"${features.join(', ')}\". Contact your administrator.`\n }\n if (effectiveRoles.length) {\n return `Access denied: this area requires the role \"${effectiveRoles.join(', ')}\". Contact your administrator.`\n }\n return 'Access denied: you do not have permission to perform this action.'\n}\n\n/**\n * Signal a forbidden access attempt for an authenticated user via a flash banner.\n *\n * Authenticated 403 responses must never redirect to `/login` \u2014 that creates an\n * infinite loop because the login page detects the active session and bounces\n * the user back to the failing destination (see GH #2070). Pages that need an\n * inline banner should catch `ForbiddenError` and render `AccessDeniedMessage`\n * from `@open-mercato/ui/backend/detail`.\n */\nexport function notifyForbiddenAccess(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n // Portal routes have their own customer auth \u2014 keep the existing no-op contract.\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash(formatForbiddenAccessMessage(options), 'warning')\n } catch {\n // no-op\n }\n}\n\n/**\n * @deprecated Renamed to {@link notifyForbiddenAccess}. The previous name\n * implied a `/login` redirect that no longer happens (see GH #2070). Kept as an\n * exported alias for one minor version so third-party module imports keep\n * building; update imports to `notifyForbiddenAccess`.\n */\nexport const redirectToForbiddenLogin = notifyForbiddenAccess\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n const originalFetch =\n typeof window !== 'undefined'\n ? (window as Window & { __omOriginalFetch?: FetchType }).__omOriginalFetch\n : undefined\n const fallbackFetch = (globalThis as typeof globalThis & { fetch?: FetchType }).fetch\n const baseFetch = originalFetch ?? fallbackFetch\n if (!baseFetch) {\n return new Response(\n JSON.stringify({ error: 'Fetch API is not available in this runtime' }),\n { status: 503, headers: { 'content-type': 'application/json' } },\n )\n }\n const scoped = scopedHeaders.resolveScopedHeaders()\n const baseInit: RequestInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init ?? {}\n // Only auto-inject credentials: 'include' for same-origin requests so cookies\n // round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)\n // without leaking session cookies to third-party hosts.\n const mergedInit: RequestInit = baseInit.credentials\n ? baseInit\n : isSameOriginRequest(input)\n ? { ...baseInit, credentials: 'include' }\n : baseInit\n const requestHeaders = new Headers(mergedInit?.headers)\n const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')\n const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')\n // Snapshot the pathname BEFORE the request is sent. A 401 for a request that\n // started on the login page must stay silent even when the response lands after\n // the post-login client-side navigation to /backend \u2014 otherwise the stale\n // pre-auth 401 raises a bogus \"Session expired\" banner right after signing in.\n const requestPathname = readPathname()\n const res = await baseFetch(input, mergedInit)\n const responsePathname = readPathname()\n const onLoginPage = isLoginPathname(requestPathname) || isLoginPathname(responsePathname)\n const onPortalRoute = isPortalPathname(requestPathname) || isPortalPathname(responsePathname)\n if (res.status === 401) {\n // Trigger same redirect flow as protected pages\n // Skip for staff login page and all portal routes (portal has its own auth)\n if (!onLoginPage && !onPortalRoute && !disableUnauthorizedRedirect) {\n redirectToSessionRefresh()\n // Throw a typed error for callers that might still handle it\n throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))\n }\n return res\n }\n if (res.status === 403) {\n // Try to read requiredRoles from JSON body; ignore if not JSON\n let roles: string[] | null = null\n let features: string[] | null = null\n let payload: unknown = null\n const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)\n if (aclData && typeof aclData === 'object') {\n if (Array.isArray(aclData.requiredRoles)) {\n roles = aclData.requiredRoles.map((r) => String(r))\n }\n if (Array.isArray(aclData.requiredFeatures)) {\n features = aclData.requiredFeatures.map((f) => String(f))\n }\n payload = aclData\n }\n // Only redirect if not already on login page or a portal route\n if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {\n const target =\n typeof input === 'string'\n ? input\n : input instanceof URL\n ? input.toString()\n : (typeof Request !== 'undefined' && input instanceof Request)\n ? input.url\n : 'unknown'\n try {\n logger.warn('Forbidden response', {\n url: target,\n status: res.status,\n requiredRoles: roles,\n requiredFeatures: features,\n details: payload,\n })\n } catch {}\n const hasAclHints = Boolean((roles && roles.length) || (features && features.length))\n if (hasAclHints) {\n notifyForbiddenAccess({ requiredRoles: roles, requiredFeatures: features })\n }\n let msg = 'Forbidden'\n if (aclData && typeof aclData === 'object') {\n if (typeof aclData.error === 'string') {\n msg = aclData.error\n } else if (typeof aclData.message === 'string') {\n msg = aclData.message\n }\n } else {\n msg = await res.clone().text().catch(() => 'Forbidden')\n }\n // Attach ACL hints so callers (e.g. flashMutationError) can name the\n // missing permission instead of surfacing a bare \"Forbidden\" toast.\n throw new ForbiddenError(msg, { requiredFeatures: features, requiredRoles: roles })\n }\n // If already on login, just return the response for the caller to handle\n }\n try {\n const header = res.headers.get('x-om-operation')\n const metadata = deserializeOperationMetadata(header)\n if (metadata) pushOperation(metadata)\n } catch {\n // ignore malformed headers\n }\n try {\n const warningRaw = res.headers.get('x-om-partial-index')\n if (warningRaw) {\n const parsed = JSON.parse(warningRaw) as Record<string, unknown>\n if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {\n const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')\n if (entity) {\n const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null\n const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null\n const scope = parsed.scope === 'global' ? 'global' : 'scoped'\n const entityLabel =\n typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()\n ? parsed.entityLabel.trim()\n : entity\n pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })\n }\n }\n }\n } catch {\n // ignore malformed headers\n }\n return res\n}\n"],
|
|
5
|
-
"mappings": ";AAGA,SAAS,aAAa;AACtB,SAAS,oCAAoC;AAC7C,SAAS,oBAAoB;AAC7B,SAAS,qBAAqB;AAC9B,SAAS,+BAA+B;AACxC,SAAS,+BAA+B;AACxC,SAAS,oBAAoB;AAE7B,MAAM,SAAS,aAAa,IAAI,EAAE,MAAM,EAAE,WAAW,WAAW,CAAC;AAEjE,MAAM,gBAAgB,wBAAwB;AAE9C,SAAS,aAAa,MAA+B,QAAyC;AAC5F,QAAM,UAAU,IAAI,QAAQ,QAAQ,CAAC,CAAC;AACtC,aAAW,CAAC,KAAK,KAAK,KAAK,OAAO,QAAQ,MAAM,GAAG;AACjD,QAAI,QAAQ,IAAI,GAAG,EAAG;AACtB,YAAQ,IAAI,KAAK,KAAK;AAAA,EACxB;AACA,SAAO;AACT;AAEA,SAAS,qBAAqB,SAAkB,YAA6B;AAC3E,SAAO,QAAQ,IAAI,UAAU,MAAM;AACrC;AAEA,SAAS,oBAAoB,OAAmC;AAC9D,MAAI,OAAO,WAAW,YAAa,QAAO;AAC1C,QAAM,OAAO,OAAO,UAAU;AAC9B,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI;AACJ,MAAI,OAAO,UAAU,SAAU,aAAY;AAAA,WAClC,iBAAiB,IAAK,aAAY,MAAM,SAAS;AAAA,WACjD,OAAO,YAAY,eAAe,iBAAiB,QAAS,aAAY,MAAM;AAAA,MAClF,QAAO;AACZ,MAAI,CAAC,uBAAuB,KAAK,SAAS,EAAG,QAAO;AACpD,MAAI;AACF,WAAO,IAAI,IAAI,SAAS,EAAE,SAAS;AAAA,EACrC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,eAAsB,qBAAwB,SAAiC,KAAmC;AAChH,SAAO,cAAc,kBAAkB,SAAS,GAAG;AACrD;AAEA,SAAS,eAAuB;AAC9B,SAAO,OAAO,WAAW,cAAc,OAAO,UAAU,YAAY,KAAK;AAC3E;AAKA,SAAS,gBAAgB,UAA2B;AAClD,SAAO,SAAS,WAAW,QAAQ,KAAK,SAAS,WAAW,QAAQ;AACtE;AAEA,SAAS,iBAAiB,UAA2B;AACnD,SAAO,wBAAwB,KAAK,QAAQ;AAC9C;AAEO,MAAM,0BAA0B,MAAM;AAAA,EAE3C,YAAY,UAAU,gBAAgB;AACpC,UAAM,OAAO;AAFf,SAAS,SAAS;AAGhB,SAAK,OAAO;AAAA,EACd;AACF;AAEA,MAAM,qCAAqC;AAC3C,MAAM,8BAA8B;AAMpC,SAAS,gCAAgC,QAAyB;AAChE,MAAI;AACF,UAAM,MAAM,OAAO,eAAe,QAAQ,qCAAqC,MAAM;AACrF,QAAI,CAAC,IAAK,QAAO;AACjB,UAAM,cAAc,OAAO,GAAG;AAC9B,WAAO,OAAO,SAAS,WAAW,KAAK,KAAK,IAAI,IAAI,cAAc;AAAA,EACpE,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,4BAA4B,QAAsB;AACzD,MAAI;AACF,WAAO,eAAe,QAAQ,qCAAqC,QAAQ,OAAO,KAAK,IAAI,CAAC,CAAC;AAAA,EAC/F,QAAQ;AAAA,EAER;AACF;AAEO,SAAS,2BAA2B;AACzC,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,UAAU,OAAO,SAAS,WAAW,OAAO,SAAS;AAE3D,MAAI,OAAO,SAAS,SAAS,WAAW,WAAW,EAAG;AAEtD,MAAI,wBAAwB,KAAK,OAAO,SAAS,QAAQ,EAAG;AAC5D,MAAI,gCAAgC,OAAO,EAAG;AAC9C,8BAA4B,OAAO;AACnC,MAAI;AACF,UAAM,
|
|
4
|
+
"sourcesContent": ["\"use client\"\n// Simple fetch wrapper that redirects to session refresh on 401 (Unauthorized)\n// Used across UI data utilities to avoid duplication.\nimport { flash } from '../FlashMessages'\nimport { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'\nimport { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'\nimport { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\nimport { createLogger } from '@open-mercato/shared/lib/logger'\nimport type { TranslateFn, TranslateParams } from '@open-mercato/shared/lib/i18n/context'\nimport { translateWithFallback } from '@open-mercato/shared/lib/i18n/translate'\n\nconst logger = createLogger('ui').child({ component: 'apiFetch' })\n\nconst fallbackAuthNoticeTranslator: TranslateFn = (key) => key\nlet authNoticeTranslator: TranslateFn | null = null\n\n/**\n * Register the active i18n translator for the session-expired and\n * access-denied flash banners, which fire outside React. Returns a cleanup\n * that unregisters it unless another translator replaced it meanwhile.\n */\nexport function registerAuthNoticeTranslator(translate: TranslateFn): () => void {\n authNoticeTranslator = translate\n return () => {\n if (authNoticeTranslator === translate) authNoticeTranslator = null\n }\n}\n\nfunction translateAuthNotice(key: string, fallback: string, params?: TranslateParams): string {\n return translateWithFallback(authNoticeTranslator ?? fallbackAuthNoticeTranslator, key, fallback, params)\n}\n\nconst scopedHeaders = createScopedHeaderStack()\n\nfunction mergeHeaders(base: HeadersInit | undefined, scoped: Record<string, string>): Headers {\n const headers = new Headers(base ?? {})\n for (const [key, value] of Object.entries(scoped)) {\n if (headers.has(key)) continue\n headers.set(key, value)\n }\n return headers\n}\n\nfunction readRedirectOverride(headers: Headers, headerName: string): boolean {\n return headers.get(headerName) === '0'\n}\n\nfunction isSameOriginRequest(input: RequestInfo | URL): boolean {\n if (typeof window === 'undefined') return false\n const host = window.location?.host\n if (!host) return false\n let urlString: string\n if (typeof input === 'string') urlString = input\n else if (input instanceof URL) urlString = input.toString()\n else if (typeof Request !== 'undefined' && input instanceof Request) urlString = input.url\n else return false\n if (!/^[a-z][a-z0-9+.-]*:/i.test(urlString)) return true\n try {\n return new URL(urlString).host === host\n } catch {\n return false\n }\n}\n\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\n}\n\nfunction readPathname(): string {\n return typeof window !== 'undefined' ? window.location?.pathname ?? '' : ''\n}\n\n// `/start` is the anonymous landing/dashboard page and, like `/login`, runs a\n// background auth-probe whose 401 is expected for a logged-out visitor \u2014 not a\n// real session problem worth surfacing (see GH #6159).\nfunction isLoginPathname(pathname: string): boolean {\n return pathname.startsWith('/login') || pathname.startsWith('/start')\n}\n\nfunction isPortalPathname(pathname: string): boolean {\n return /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\n}\n\nexport class UnauthorizedError extends Error {\n readonly status = 401\n constructor(message = 'Unauthorized') {\n super(message)\n this.name = 'UnauthorizedError'\n }\n}\n\nconst SESSION_REFRESH_ATTEMPT_KEY_PREFIX = 'om:session-refresh-attempt:'\nconst SESSION_REFRESH_COOLDOWN_MS = 10_000\n\n// A genuinely expired session redirects once per target, then goes quiet: the\n// refresh always succeeds (the session cookie is fine), bounces back to the\n// same URL, and re-triggers the same 401 for any non-session cause \u2014 without\n// this guard that bounce repeats forever (GH #5186).\nfunction recentlyAttemptedSessionRefresh(target: string): boolean {\n try {\n const raw = window.sessionStorage.getItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target)\n if (!raw) return false\n const attemptedAt = Number(raw)\n return Number.isFinite(attemptedAt) && Date.now() - attemptedAt < SESSION_REFRESH_COOLDOWN_MS\n } catch {\n return false\n }\n}\n\nfunction recordSessionRefreshAttempt(target: string): void {\n try {\n window.sessionStorage.setItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target, String(Date.now()))\n } catch {\n // no-op\n }\n}\n\nexport function redirectToSessionRefresh() {\n if (typeof window === 'undefined') return\n const current = window.location.pathname + window.location.search\n // Avoid redirect loops if already on an auth/session route\n if (window.location.pathname.startsWith('/api/auth')) return\n // Portal routes have their own customer auth \u2014 never redirect to staff login\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n if (recentlyAttemptedSessionRefresh(current)) return\n recordSessionRefreshAttempt(current)\n try {\n flash(translateAuthNotice('ui.auth.sessionExpired', 'Session expired. Redirecting to sign in\u2026'), 'warning')\n setTimeout(() => {\n window.location.href = `/api/auth/session/refresh?redirect=${encodeURIComponent(current)}`\n }, 20)\n } catch {\n // no-op\n }\n}\n\nexport class ForbiddenError extends Error {\n readonly status = 403\n readonly requiredFeatures: string[] | null\n readonly requiredRoles: string[] | null\n\n constructor(\n message = 'Forbidden',\n options?: { requiredFeatures?: string[] | null; requiredRoles?: string[] | null },\n ) {\n super(message)\n this.name = 'ForbiddenError'\n this.requiredFeatures = options?.requiredFeatures?.length ? [...options.requiredFeatures] : null\n this.requiredRoles = options?.requiredRoles?.length ? [...options.requiredRoles] : null\n }\n}\n\nlet DEFAULT_FORBIDDEN_ROLES: string[] = ['admin']\n\nexport function setAuthRedirectConfig(cfg: { defaultForbiddenRoles?: readonly string[] }) {\n if (cfg?.defaultForbiddenRoles && cfg.defaultForbiddenRoles.length) {\n DEFAULT_FORBIDDEN_ROLES = [...cfg.defaultForbiddenRoles].map(String)\n }\n}\n\nfunction formatForbiddenAccessMessage(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }): string {\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const effectiveRoles = roles.length ? roles : DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n if (features.length) {\n return translateAuthNotice(\n 'ui.auth.accessDenied.missingFeatures',\n 'Access denied: you are missing the required permission \"{features}\". Contact your administrator.',\n { features: features.join(', ') },\n )\n }\n if (effectiveRoles.length) {\n return translateAuthNotice(\n 'ui.auth.accessDenied.requiredRoles',\n 'Access denied: this area requires the role \"{roles}\". Contact your administrator.',\n { roles: effectiveRoles.join(', ') },\n )\n }\n return translateAuthNotice('ui.auth.accessDenied.generic', 'Access denied: you do not have permission to perform this action.')\n}\n\n/**\n * Signal a forbidden access attempt for an authenticated user via a flash banner.\n *\n * Authenticated 403 responses must never redirect to `/login` \u2014 that creates an\n * infinite loop because the login page detects the active session and bounces\n * the user back to the failing destination (see GH #2070). Pages that need an\n * inline banner should catch `ForbiddenError` and render `AccessDeniedMessage`\n * from `@open-mercato/ui/backend/detail`.\n */\nexport function notifyForbiddenAccess(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n // Portal routes have their own customer auth \u2014 keep the existing no-op contract.\n if (/\\/[^/]+\\/portal(\\/|$)/.test(window.location.pathname)) return\n try {\n flash(formatForbiddenAccessMessage(options), 'warning')\n } catch {\n // no-op\n }\n}\n\n/**\n * @deprecated Renamed to {@link notifyForbiddenAccess}. The previous name\n * implied a `/login` redirect that no longer happens (see GH #2070). Kept as an\n * exported alias for one minor version so third-party module imports keep\n * building; update imports to `notifyForbiddenAccess`.\n */\nexport const redirectToForbiddenLogin = notifyForbiddenAccess\n\nexport async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Response> {\n type FetchType = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>\n const originalFetch =\n typeof window !== 'undefined'\n ? (window as Window & { __omOriginalFetch?: FetchType }).__omOriginalFetch\n : undefined\n const fallbackFetch = (globalThis as typeof globalThis & { fetch?: FetchType }).fetch\n const baseFetch = originalFetch ?? fallbackFetch\n if (!baseFetch) {\n return new Response(\n JSON.stringify({ error: 'Fetch API is not available in this runtime' }),\n { status: 503, headers: { 'content-type': 'application/json' } },\n )\n }\n const scoped = scopedHeaders.resolveScopedHeaders()\n const baseInit: RequestInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init ?? {}\n // Only auto-inject credentials: 'include' for same-origin requests so cookies\n // round-trip across Next.js proxy.ts rewrites (custom-domain portal flows)\n // without leaking session cookies to third-party hosts.\n const mergedInit: RequestInit = baseInit.credentials\n ? baseInit\n : isSameOriginRequest(input)\n ? { ...baseInit, credentials: 'include' }\n : baseInit\n const requestHeaders = new Headers(mergedInit?.headers)\n const disableUnauthorizedRedirect = readRedirectOverride(requestHeaders, 'x-om-unauthorized-redirect')\n const disableForbiddenRedirect = readRedirectOverride(requestHeaders, 'x-om-forbidden-redirect')\n // Snapshot the pathname BEFORE the request is sent. A 401 for a request that\n // started on the login page must stay silent even when the response lands after\n // the post-login client-side navigation to /backend \u2014 otherwise the stale\n // pre-auth 401 raises a bogus \"Session expired\" banner right after signing in.\n const requestPathname = readPathname()\n const res = await baseFetch(input, mergedInit)\n const responsePathname = readPathname()\n const onLoginPage = isLoginPathname(requestPathname) || isLoginPathname(responsePathname)\n const onPortalRoute = isPortalPathname(requestPathname) || isPortalPathname(responsePathname)\n if (res.status === 401) {\n // Trigger same redirect flow as protected pages\n // Skip for staff login page and all portal routes (portal has its own auth)\n if (!onLoginPage && !onPortalRoute && !disableUnauthorizedRedirect) {\n redirectToSessionRefresh()\n // Throw a typed error for callers that might still handle it\n throw new UnauthorizedError(await res.text().catch(() => 'Unauthorized'))\n }\n return res\n }\n if (res.status === 403) {\n // Try to read requiredRoles from JSON body; ignore if not JSON\n let roles: string[] | null = null\n let features: string[] | null = null\n let payload: unknown = null\n const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)\n if (aclData && typeof aclData === 'object') {\n if (Array.isArray(aclData.requiredRoles)) {\n roles = aclData.requiredRoles.map((r) => String(r))\n }\n if (Array.isArray(aclData.requiredFeatures)) {\n features = aclData.requiredFeatures.map((f) => String(f))\n }\n payload = aclData\n }\n // Only redirect if not already on login page or a portal route\n if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {\n const target =\n typeof input === 'string'\n ? input\n : input instanceof URL\n ? input.toString()\n : (typeof Request !== 'undefined' && input instanceof Request)\n ? input.url\n : 'unknown'\n try {\n logger.warn('Forbidden response', {\n url: target,\n status: res.status,\n requiredRoles: roles,\n requiredFeatures: features,\n details: payload,\n })\n } catch {}\n const hasAclHints = Boolean((roles && roles.length) || (features && features.length))\n if (hasAclHints) {\n notifyForbiddenAccess({ requiredRoles: roles, requiredFeatures: features })\n }\n let msg = 'Forbidden'\n if (aclData && typeof aclData === 'object') {\n if (typeof aclData.error === 'string') {\n msg = aclData.error\n } else if (typeof aclData.message === 'string') {\n msg = aclData.message\n }\n } else {\n msg = await res.clone().text().catch(() => 'Forbidden')\n }\n // Attach ACL hints so callers (e.g. flashMutationError) can name the\n // missing permission instead of surfacing a bare \"Forbidden\" toast.\n throw new ForbiddenError(msg, { requiredFeatures: features, requiredRoles: roles })\n }\n // If already on login, just return the response for the caller to handle\n }\n try {\n const header = res.headers.get('x-om-operation')\n const metadata = deserializeOperationMetadata(header)\n if (metadata) pushOperation(metadata)\n } catch {\n // ignore malformed headers\n }\n try {\n const warningRaw = res.headers.get('x-om-partial-index')\n if (warningRaw) {\n const parsed = JSON.parse(warningRaw) as Record<string, unknown>\n if (parsed && typeof parsed === 'object' && parsed.type === 'partial_index') {\n const entity = typeof parsed.entity === 'string' ? parsed.entity : String(parsed.entity ?? '')\n if (entity) {\n const baseCount = typeof parsed.baseCount === 'number' ? parsed.baseCount : null\n const indexedCount = typeof parsed.indexedCount === 'number' ? parsed.indexedCount : null\n const scope = parsed.scope === 'global' ? 'global' : 'scoped'\n const entityLabel =\n typeof parsed.entityLabel === 'string' && parsed.entityLabel.trim()\n ? parsed.entityLabel.trim()\n : entity\n pushPartialIndexWarning({ entity, entityLabel, baseCount, indexedCount, scope })\n }\n }\n }\n } catch {\n // ignore malformed headers\n }\n return res\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
const UNGROUPED_GROUP_ID = "__om_ungrouped__";
|
|
2
|
+
const TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(\.[A-Za-z0-9_-]+)+$/;
|
|
3
|
+
function isTranslationKeyShaped(group) {
|
|
4
|
+
return TRANSLATION_KEY_PATTERN.test(group);
|
|
5
|
+
}
|
|
6
|
+
function resolveGroupLabel(translate, group, ungroupedKey, ungroupedFallback) {
|
|
7
|
+
if (group === UNGROUPED_GROUP_ID) return translate(ungroupedKey, ungroupedFallback);
|
|
8
|
+
if (!isTranslationKeyShaped(group)) return group;
|
|
9
|
+
return translate(group, group);
|
|
10
|
+
}
|
|
11
|
+
export {
|
|
12
|
+
UNGROUPED_GROUP_ID,
|
|
13
|
+
isTranslationKeyShaped,
|
|
14
|
+
resolveGroupLabel
|
|
15
|
+
};
|
|
16
|
+
//# sourceMappingURL=groupLabels.js.map
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
{
|
|
2
|
+
"version": 3,
|
|
3
|
+
"sources": ["../../../src/backend/utils/groupLabels.ts"],
|
|
4
|
+
"sourcesContent": ["// packages/ui/src/backend/utils/groupLabels.ts\n\n// Stable, locale-independent identity for the \"no declared group\" bucket. Grouping and\n// collapse state key on this sentinel so two locales never split one bucket in two.\nexport const UNGROUPED_GROUP_ID = '__om_ungrouped__'\n\nconst TRANSLATION_KEY_PATTERN = /^[A-Za-z0-9_-]+(\\.[A-Za-z0-9_-]+)+$/\n\n// Group strings reach these components from two very different places: code literals a\n// module author controls, and free text a tenant types into the custom-field fieldset\n// editor. Only the former may be resolved as a translation key, so tenant data is never\n// silently reinterpreted as a dictionary lookup.\nexport function isTranslationKeyShaped(group: string): boolean {\n return TRANSLATION_KEY_PATTERN.test(group)\n}\n\nexport function resolveGroupLabel(\n translate: (key: string, fallback?: string) => string,\n group: string,\n ungroupedKey: string,\n ungroupedFallback: string,\n): string {\n if (group === UNGROUPED_GROUP_ID) return translate(ungroupedKey, ungroupedFallback)\n if (!isTranslationKeyShaped(group)) return group\n return translate(group, group)\n}\n"],
|
|
5
|
+
"mappings": "AAIO,MAAM,qBAAqB;AAElC,MAAM,0BAA0B;AAMzB,SAAS,uBAAuB,OAAwB;AAC7D,SAAO,wBAAwB,KAAK,KAAK;AAC3C;AAEO,SAAS,kBACd,WACA,OACA,cACA,mBACQ;AACR,MAAI,UAAU,mBAAoB,QAAO,UAAU,cAAc,iBAAiB;AAClF,MAAI,CAAC,uBAAuB,KAAK,EAAG,QAAO;AAC3C,SAAO,UAAU,OAAO,KAAK;AAC/B;",
|
|
6
|
+
"names": []
|
|
7
|
+
}
|
|
@@ -3,7 +3,8 @@ import { jsx } from "react/jsx-runtime";
|
|
|
3
3
|
import * as React from "react";
|
|
4
4
|
import { MutationCache, QueryCache, QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
5
5
|
import { parseBooleanWithDefault } from "@open-mercato/shared/lib/boolean";
|
|
6
|
-
import {
|
|
6
|
+
import { useOptionalT } from "@open-mercato/shared/lib/i18n/context";
|
|
7
|
+
import { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig, registerAuthNoticeTranslator } from "../backend/utils/api.js";
|
|
7
8
|
function ensureGlobalFetchInterception() {
|
|
8
9
|
if (typeof window === "undefined") return;
|
|
9
10
|
const w = window;
|
|
@@ -57,6 +58,11 @@ const client = new QueryClient({
|
|
|
57
58
|
})
|
|
58
59
|
});
|
|
59
60
|
function QueryProvider({ children, defaultForbiddenRoles }) {
|
|
61
|
+
const t = useOptionalT();
|
|
62
|
+
React.useEffect(() => {
|
|
63
|
+
if (!t) return;
|
|
64
|
+
return registerAuthNoticeTranslator(t);
|
|
65
|
+
}, [t]);
|
|
60
66
|
React.useEffect(() => {
|
|
61
67
|
ensureGlobalFetchInterception();
|
|
62
68
|
if (defaultForbiddenRoles && defaultForbiddenRoles.length) {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/theme/QueryProvider.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig } from '../backend/utils/api'\n\n// Ensure global fetch calls also flow through apiFetch so 401 session-refresh\n// and 403 access-denied flash banners fire consistently.\nfunction ensureGlobalFetchInterception() {\n if (typeof window === 'undefined') return\n const w = window as any\n if (w.__omFetchPatched) return\n w.__omFetchPatched = true\n w.__omOriginalFetch = window.fetch\n window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => apiFetch(input, init)) as any\n}\n\nexport const DEFAULT_QUERY_STALE_TIME_MS = 30_000\nconst QUERY_RETRY_LIMIT = 2\n\nfunction readErrorStatus(error: unknown): number | null {\n const status = (error as { status?: unknown })?.status\n if (typeof status === 'number') return status\n const responseStatus = (error as { response?: { status?: unknown } })?.response?.status\n return typeof responseStatus === 'number' ? responseStatus : null\n}\n\nexport function shouldRetryQuery(failureCount: number, error: unknown): boolean {\n const status = readErrorStatus(error)\n if (status !== null && status >= 400 && status <= 404) return false\n return failureCount < QUERY_RETRY_LIMIT\n}\n\nexport function buildDefaultQueryOptions() {\n const enableDefaultStaleTime = parseBooleanWithDefault(\n process.env.NEXT_PUBLIC_OM_QUERY_DEFAULT_STALE_TIME_ENABLED,\n false,\n )\n return {\n retry: shouldRetryQuery,\n ...(enableDefaultStaleTime ? { staleTime: DEFAULT_QUERY_STALE_TIME_MS } : {}),\n }\n}\n\nconst client = new QueryClient({\n defaultOptions: {\n queries: buildDefaultQueryOptions(),\n },\n queryCache: new QueryCache({\n onError: (error) => {\n if (error instanceof UnauthorizedError) redirectToSessionRefresh()\n else if (error instanceof ForbiddenError) notifyForbiddenAccess()\n // As a fallback, try to detect common cases\n else if ((error as any)?.status === 401) redirectToSessionRefresh()\n else if ((error as any)?.status === 403) notifyForbiddenAccess()\n },\n }),\n mutationCache: new MutationCache({\n onError: (error) => {\n if (error instanceof UnauthorizedError) redirectToSessionRefresh()\n else if (error instanceof ForbiddenError) notifyForbiddenAccess()\n else if ((error as any)?.status === 401) redirectToSessionRefresh()\n else if ((error as any)?.status === 403) notifyForbiddenAccess()\n },\n }),\n})\n\ntype QueryProviderProps = { children: React.ReactNode; defaultForbiddenRoles?: string[] }\n\nexport function QueryProvider({ children, defaultForbiddenRoles }: QueryProviderProps) {\n React.useEffect(() => {\n ensureGlobalFetchInterception()\n if (defaultForbiddenRoles && defaultForbiddenRoles.length) {\n setAuthRedirectConfig({ defaultForbiddenRoles })\n }\n }, [])\n return <QueryClientProvider client={client}>{children}</QueryClientProvider>\n}\n"],
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { MutationCache, QueryCache, QueryClient, QueryClientProvider } from '@tanstack/react-query'\nimport { parseBooleanWithDefault } from '@open-mercato/shared/lib/boolean'\nimport { useOptionalT } from '@open-mercato/shared/lib/i18n/context'\nimport { redirectToSessionRefresh, notifyForbiddenAccess, UnauthorizedError, ForbiddenError, apiFetch, setAuthRedirectConfig, registerAuthNoticeTranslator } from '../backend/utils/api'\n\n// Ensure global fetch calls also flow through apiFetch so 401 session-refresh\n// and 403 access-denied flash banners fire consistently.\nfunction ensureGlobalFetchInterception() {\n if (typeof window === 'undefined') return\n const w = window as any\n if (w.__omFetchPatched) return\n w.__omFetchPatched = true\n w.__omOriginalFetch = window.fetch\n window.fetch = ((input: RequestInfo | URL, init?: RequestInit) => apiFetch(input, init)) as any\n}\n\nexport const DEFAULT_QUERY_STALE_TIME_MS = 30_000\nconst QUERY_RETRY_LIMIT = 2\n\nfunction readErrorStatus(error: unknown): number | null {\n const status = (error as { status?: unknown })?.status\n if (typeof status === 'number') return status\n const responseStatus = (error as { response?: { status?: unknown } })?.response?.status\n return typeof responseStatus === 'number' ? responseStatus : null\n}\n\nexport function shouldRetryQuery(failureCount: number, error: unknown): boolean {\n const status = readErrorStatus(error)\n if (status !== null && status >= 400 && status <= 404) return false\n return failureCount < QUERY_RETRY_LIMIT\n}\n\nexport function buildDefaultQueryOptions() {\n const enableDefaultStaleTime = parseBooleanWithDefault(\n process.env.NEXT_PUBLIC_OM_QUERY_DEFAULT_STALE_TIME_ENABLED,\n false,\n )\n return {\n retry: shouldRetryQuery,\n ...(enableDefaultStaleTime ? { staleTime: DEFAULT_QUERY_STALE_TIME_MS } : {}),\n }\n}\n\nconst client = new QueryClient({\n defaultOptions: {\n queries: buildDefaultQueryOptions(),\n },\n queryCache: new QueryCache({\n onError: (error) => {\n if (error instanceof UnauthorizedError) redirectToSessionRefresh()\n else if (error instanceof ForbiddenError) notifyForbiddenAccess()\n // As a fallback, try to detect common cases\n else if ((error as any)?.status === 401) redirectToSessionRefresh()\n else if ((error as any)?.status === 403) notifyForbiddenAccess()\n },\n }),\n mutationCache: new MutationCache({\n onError: (error) => {\n if (error instanceof UnauthorizedError) redirectToSessionRefresh()\n else if (error instanceof ForbiddenError) notifyForbiddenAccess()\n else if ((error as any)?.status === 401) redirectToSessionRefresh()\n else if ((error as any)?.status === 403) notifyForbiddenAccess()\n },\n }),\n})\n\ntype QueryProviderProps = { children: React.ReactNode; defaultForbiddenRoles?: string[] }\n\nexport function QueryProvider({ children, defaultForbiddenRoles }: QueryProviderProps) {\n const t = useOptionalT()\n React.useEffect(() => {\n if (!t) return\n return registerAuthNoticeTranslator(t)\n }, [t])\n React.useEffect(() => {\n ensureGlobalFetchInterception()\n if (defaultForbiddenRoles && defaultForbiddenRoles.length) {\n setAuthRedirectConfig({ defaultForbiddenRoles })\n }\n }, [])\n return <QueryClientProvider client={client}>{children}</QueryClientProvider>\n}\n"],
|
|
5
|
+
"mappings": ";AAkFS;AAjFT,YAAY,WAAW;AACvB,SAAS,eAAe,YAAY,aAAa,2BAA2B;AAC5E,SAAS,+BAA+B;AACxC,SAAS,oBAAoB;AAC7B,SAAS,0BAA0B,uBAAuB,mBAAmB,gBAAgB,UAAU,uBAAuB,oCAAoC;AAIlK,SAAS,gCAAgC;AACvC,MAAI,OAAO,WAAW,YAAa;AACnC,QAAM,IAAI;AACV,MAAI,EAAE,iBAAkB;AACxB,IAAE,mBAAmB;AACrB,IAAE,oBAAoB,OAAO;AAC7B,SAAO,SAAS,CAAC,OAA0B,SAAuB,SAAS,OAAO,IAAI;AACxF;AAEO,MAAM,8BAA8B;AAC3C,MAAM,oBAAoB;AAE1B,SAAS,gBAAgB,OAA+B;AACtD,QAAM,SAAU,OAAgC;AAChD,MAAI,OAAO,WAAW,SAAU,QAAO;AACvC,QAAM,iBAAkB,OAA+C,UAAU;AACjF,SAAO,OAAO,mBAAmB,WAAW,iBAAiB;AAC/D;AAEO,SAAS,iBAAiB,cAAsB,OAAyB;AAC9E,QAAM,SAAS,gBAAgB,KAAK;AACpC,MAAI,WAAW,QAAQ,UAAU,OAAO,UAAU,IAAK,QAAO;AAC9D,SAAO,eAAe;AACxB;AAEO,SAAS,2BAA2B;AACzC,QAAM,yBAAyB;AAAA,IAC7B,QAAQ,IAAI;AAAA,IACZ;AAAA,EACF;AACA,SAAO;AAAA,IACL,OAAO;AAAA,IACP,GAAI,yBAAyB,EAAE,WAAW,4BAA4B,IAAI,CAAC;AAAA,EAC7E;AACF;AAEA,MAAM,SAAS,IAAI,YAAY;AAAA,EAC7B,gBAAgB;AAAA,IACd,SAAS,yBAAyB;AAAA,EACpC;AAAA,EACA,YAAY,IAAI,WAAW;AAAA,IACzB,SAAS,CAAC,UAAU;AAClB,UAAI,iBAAiB,kBAAmB,0BAAyB;AAAA,eACxD,iBAAiB,eAAgB,uBAAsB;AAAA,eAEtD,OAAe,WAAW,IAAK,0BAAyB;AAAA,eACxD,OAAe,WAAW,IAAK,uBAAsB;AAAA,IACjE;AAAA,EACF,CAAC;AAAA,EACD,eAAe,IAAI,cAAc;AAAA,IAC/B,SAAS,CAAC,UAAU;AAClB,UAAI,iBAAiB,kBAAmB,0BAAyB;AAAA,eACxD,iBAAiB,eAAgB,uBAAsB;AAAA,eACtD,OAAe,WAAW,IAAK,0BAAyB;AAAA,eACxD,OAAe,WAAW,IAAK,uBAAsB;AAAA,IACjE;AAAA,EACF,CAAC;AACH,CAAC;AAIM,SAAS,cAAc,EAAE,UAAU,sBAAsB,GAAuB;AACrF,QAAM,IAAI,aAAa;AACvB,QAAM,UAAU,MAAM;AACpB,QAAI,CAAC,EAAG;AACR,WAAO,6BAA6B,CAAC;AAAA,EACvC,GAAG,CAAC,CAAC,CAAC;AACN,QAAM,UAAU,MAAM;AACpB,kCAA8B;AAC9B,QAAI,yBAAyB,sBAAsB,QAAQ;AACzD,4BAAsB,EAAE,sBAAsB,CAAC;AAAA,IACjD;AAAA,EACF,GAAG,CAAC,CAAC;AACL,SAAO,oBAAC,uBAAoB,QAAiB,UAAS;AACxD;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.9.1-develop.
|
|
3
|
+
"version": "0.9.1-develop.7515.1.e2712c8b00",
|
|
4
4
|
"license": "MIT",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./dist/index.js",
|
|
@@ -155,14 +155,14 @@
|
|
|
155
155
|
"remark-gfm": "^4.0.1"
|
|
156
156
|
},
|
|
157
157
|
"peerDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
158
|
+
"@open-mercato/shared": "0.9.1-develop.7515.1.e2712c8b00",
|
|
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
164
|
"@figma/code-connect": "^1.3.4",
|
|
165
|
-
"@open-mercato/shared": "0.9.1-develop.
|
|
165
|
+
"@open-mercato/shared": "0.9.1-develop.7515.1.e2712c8b00",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.1",
|
|
168
168
|
"@testing-library/react": "^16.3.3",
|