@open-mercato/ui 0.6.8-develop.6917.1.af45bc96e2 → 0.6.8-develop.6924.1.a8d208fcdc
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/CrudForm.js +2 -2
- package/dist/backend/CrudForm.js.map +2 -2
- package/dist/backend/fields/phone.js +1 -0
- package/dist/backend/fields/phone.js.map +2 -2
- package/dist/backend/fields/registry.js +3 -0
- package/dist/backend/fields/registry.js.map +2 -2
- package/dist/backend/utils/api.js +20 -0
- package/dist/backend/utils/api.js.map +2 -2
- package/dist/backend/utils/customFieldForms.js +2 -0
- package/dist/backend/utils/customFieldForms.js.map +2 -2
- package/package.json +3 -3
- package/src/backend/CrudForm.tsx +6 -2
- package/src/backend/__tests__/CrudForm.validation.test.tsx +52 -0
- package/src/backend/__tests__/custom-field-forms.test.ts +3 -1
- package/src/backend/fields/__tests__/phone.test.tsx +1 -0
- package/src/backend/fields/phone.tsx +1 -0
- package/src/backend/fields/registry.ts +9 -1
- package/src/backend/utils/__tests__/api.test.ts +15 -0
- package/src/backend/utils/api.ts +28 -0
- package/src/backend/utils/customFieldForms.ts +2 -0
|
@@ -60,6 +60,7 @@ function PhoneFieldDefEditor({
|
|
|
60
60
|
}
|
|
61
61
|
FieldRegistry.register("phone", {
|
|
62
62
|
input: (props) => /* @__PURE__ */ jsx(PhoneFieldInput, { ...props }),
|
|
63
|
+
inputRendersOwnError: true,
|
|
63
64
|
defEditor: (props) => /* @__PURE__ */ jsx(PhoneFieldDefEditor, { ...props })
|
|
64
65
|
});
|
|
65
66
|
//# sourceMappingURL=phone.js.map
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/fields/phone.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { CrudCustomFieldRenderProps } from '../CrudForm'\nimport { FieldRegistry } from './registry'\nimport { PhoneNumberField, PHONE_COUNTRIES } from '../inputs/PhoneNumberField'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectItemLeading,\n SelectTrigger,\n SelectValue,\n} from '../../primitives/select'\n\ntype PhoneFieldConfig = {\n defaultCountryIso2?: string\n}\n\ntype PhoneFieldDef = PhoneFieldConfig & {\n configJson?: PhoneFieldConfig\n}\n\ntype PhoneFieldInputProps = CrudCustomFieldRenderProps & { def?: PhoneFieldDef }\n\n// Sentinel used by the definition editor to represent \"no fixed default\"\n// because Radix Select cannot use an empty string as an item value.\nconst AUTO_COUNTRY_VALUE = '__om_phone_auto__'\n\nfunction PhoneFieldInput({ id, value, setValue, disabled, error, def }: PhoneFieldInputProps) {\n const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)\n return (\n <PhoneNumberField\n id={id}\n value={stringValue}\n onValueChange={(next) => setValue(next ?? undefined)}\n disabled={disabled}\n externalError={error ?? null}\n defaultCountryIso2={def?.defaultCountryIso2}\n />\n )\n}\n\nfunction PhoneFieldDefEditor({\n def,\n onChange,\n}: {\n def: { configJson?: PhoneFieldConfig } | undefined\n onChange: (patch: Partial<PhoneFieldConfig>) => void\n}) {\n const t = useT()\n const selected = typeof def?.configJson?.defaultCountryIso2 === 'string' ? def.configJson.defaultCountryIso2 : ''\n return (\n <div className=\"mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3\">\n <div className=\"space-y-1\">\n <label className=\"text-xs font-medium text-muted-foreground\">\n {t('ui.customFields.phone.defaultCountry', 'Default country')}\n </label>\n <Select\n value={selected || AUTO_COUNTRY_VALUE}\n onValueChange={(next) => onChange({ defaultCountryIso2: next === AUTO_COUNTRY_VALUE ? undefined : next })}\n >\n <SelectTrigger size=\"sm\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent align=\"start\">\n <SelectItem value={AUTO_COUNTRY_VALUE}>\n {t('ui.customFields.phone.defaultCountryAuto', 'Auto-detect from value')}\n </SelectItem>\n {PHONE_COUNTRIES.map((country) => (\n <SelectItem key={`${country.iso2}-${country.dialCode}`} value={country.iso2}>\n <SelectItemLeading>\n <span className=\"text-base leading-none\">{country.flag}</span>\n </SelectItemLeading>\n <span className=\"flex-1 truncate\">{country.label}</span>\n <span className=\"ml-2 text-xs text-muted-foreground tabular-nums\">{country.dialCode}</span>\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'ui.customFields.phone.defaultCountryHint',\n 'Pre-selects a country in the phone editor when the field is empty.',\n )}\n </p>\n </div>\n </div>\n )\n}\n\nFieldRegistry.register('phone', {\n input: (props) => <PhoneFieldInput {...props} />,\n defEditor: (props) => <PhoneFieldDefEditor {...props} />,\n})\n\nexport {}\n"],
|
|
5
|
-
"mappings": ";AAiCI,cAsCU,YAtCV;AA7BJ,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB,uBAAuB;AAClD,SAAS,YAAY;AACrB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAcP,MAAM,qBAAqB;AAE3B,SAAS,gBAAgB,EAAE,IAAI,OAAO,UAAU,UAAU,OAAO,IAAI,GAAyB;AAC5F,QAAM,cAAc,OAAO,UAAU,WAAW,QAAQ,SAAS,OAAO,KAAK,OAAO,KAAK;AACzF,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO;AAAA,MACP,eAAe,CAAC,SAAS,SAAS,QAAQ,MAAS;AAAA,MACnD;AAAA,MACA,eAAe,SAAS;AAAA,MACxB,oBAAoB,KAAK;AAAA;AAAA,EAC3B;AAEJ;AAEA,SAAS,oBAAoB;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,QAAM,IAAI,KAAK;AACf,QAAM,WAAW,OAAO,KAAK,YAAY,uBAAuB,WAAW,IAAI,WAAW,qBAAqB;AAC/G,SACE,oBAAC,SAAI,WAAU,0FACb,+BAAC,SAAI,WAAU,aACb;AAAA,wBAAC,WAAM,WAAU,6CACd,YAAE,wCAAwC,iBAAiB,GAC9D;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,OAAO,YAAY;AAAA,QACnB,eAAe,CAAC,SAAS,SAAS,EAAE,oBAAoB,SAAS,qBAAqB,SAAY,KAAK,CAAC;AAAA,QAExG;AAAA,8BAAC,iBAAc,MAAK,MAClB,8BAAC,eAAY,GACf;AAAA,UACA,qBAAC,iBAAc,OAAM,SACnB;AAAA,gCAAC,cAAW,OAAO,oBAChB,YAAE,4CAA4C,wBAAwB,GACzE;AAAA,YACC,gBAAgB,IAAI,CAAC,YACpB,qBAAC,cAAuD,OAAO,QAAQ,MACrE;AAAA,kCAAC,qBACC,8BAAC,UAAK,WAAU,0BAA0B,kBAAQ,MAAK,GACzD;AAAA,cACA,oBAAC,UAAK,WAAU,mBAAmB,kBAAQ,OAAM;AAAA,cACjD,oBAAC,UAAK,WAAU,mDAAmD,kBAAQ,UAAS;AAAA,iBALrE,GAAG,QAAQ,IAAI,IAAI,QAAQ,QAAQ,EAMpD,CACD;AAAA,aACH;AAAA;AAAA;AAAA,IACF;AAAA,IACA,oBAAC,OAAE,WAAU,iCACV;AAAA,MACC;AAAA,MACA;AAAA,IACF,GACF;AAAA,KACF,GACF;AAEJ;AAEA,cAAc,SAAS,SAAS;AAAA,EAC9B,OAAO,CAAC,UAAU,oBAAC,mBAAiB,GAAG,OAAO;AAAA,EAC9C,WAAW,CAAC,UAAU,oBAAC,uBAAqB,GAAG,OAAO;AACxD,CAAC;",
|
|
4
|
+
"sourcesContent": ["\"use client\"\n\nimport * as React from 'react'\nimport type { CrudCustomFieldRenderProps } from '../CrudForm'\nimport { FieldRegistry } from './registry'\nimport { PhoneNumberField, PHONE_COUNTRIES } from '../inputs/PhoneNumberField'\nimport { useT } from '@open-mercato/shared/lib/i18n/context'\nimport {\n Select,\n SelectContent,\n SelectItem,\n SelectItemLeading,\n SelectTrigger,\n SelectValue,\n} from '../../primitives/select'\n\ntype PhoneFieldConfig = {\n defaultCountryIso2?: string\n}\n\ntype PhoneFieldDef = PhoneFieldConfig & {\n configJson?: PhoneFieldConfig\n}\n\ntype PhoneFieldInputProps = CrudCustomFieldRenderProps & { def?: PhoneFieldDef }\n\n// Sentinel used by the definition editor to represent \"no fixed default\"\n// because Radix Select cannot use an empty string as an item value.\nconst AUTO_COUNTRY_VALUE = '__om_phone_auto__'\n\nfunction PhoneFieldInput({ id, value, setValue, disabled, error, def }: PhoneFieldInputProps) {\n const stringValue = typeof value === 'string' ? value : value == null ? '' : String(value)\n return (\n <PhoneNumberField\n id={id}\n value={stringValue}\n onValueChange={(next) => setValue(next ?? undefined)}\n disabled={disabled}\n externalError={error ?? null}\n defaultCountryIso2={def?.defaultCountryIso2}\n />\n )\n}\n\nfunction PhoneFieldDefEditor({\n def,\n onChange,\n}: {\n def: { configJson?: PhoneFieldConfig } | undefined\n onChange: (patch: Partial<PhoneFieldConfig>) => void\n}) {\n const t = useT()\n const selected = typeof def?.configJson?.defaultCountryIso2 === 'string' ? def.configJson.defaultCountryIso2 : ''\n return (\n <div className=\"mt-3 space-y-3 rounded border border-dashed border-muted-foreground/40 bg-muted/30 p-3\">\n <div className=\"space-y-1\">\n <label className=\"text-xs font-medium text-muted-foreground\">\n {t('ui.customFields.phone.defaultCountry', 'Default country')}\n </label>\n <Select\n value={selected || AUTO_COUNTRY_VALUE}\n onValueChange={(next) => onChange({ defaultCountryIso2: next === AUTO_COUNTRY_VALUE ? undefined : next })}\n >\n <SelectTrigger size=\"sm\">\n <SelectValue />\n </SelectTrigger>\n <SelectContent align=\"start\">\n <SelectItem value={AUTO_COUNTRY_VALUE}>\n {t('ui.customFields.phone.defaultCountryAuto', 'Auto-detect from value')}\n </SelectItem>\n {PHONE_COUNTRIES.map((country) => (\n <SelectItem key={`${country.iso2}-${country.dialCode}`} value={country.iso2}>\n <SelectItemLeading>\n <span className=\"text-base leading-none\">{country.flag}</span>\n </SelectItemLeading>\n <span className=\"flex-1 truncate\">{country.label}</span>\n <span className=\"ml-2 text-xs text-muted-foreground tabular-nums\">{country.dialCode}</span>\n </SelectItem>\n ))}\n </SelectContent>\n </Select>\n <p className=\"text-xs text-muted-foreground\">\n {t(\n 'ui.customFields.phone.defaultCountryHint',\n 'Pre-selects a country in the phone editor when the field is empty.',\n )}\n </p>\n </div>\n </div>\n )\n}\n\nFieldRegistry.register('phone', {\n input: (props) => <PhoneFieldInput {...props} />,\n inputRendersOwnError: true,\n defEditor: (props) => <PhoneFieldDefEditor {...props} />,\n})\n\nexport {}\n"],
|
|
5
|
+
"mappings": ";AAiCI,cAsCU,YAtCV;AA7BJ,SAAS,qBAAqB;AAC9B,SAAS,kBAAkB,uBAAuB;AAClD,SAAS,YAAY;AACrB;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAcP,MAAM,qBAAqB;AAE3B,SAAS,gBAAgB,EAAE,IAAI,OAAO,UAAU,UAAU,OAAO,IAAI,GAAyB;AAC5F,QAAM,cAAc,OAAO,UAAU,WAAW,QAAQ,SAAS,OAAO,KAAK,OAAO,KAAK;AACzF,SACE;AAAA,IAAC;AAAA;AAAA,MACC;AAAA,MACA,OAAO;AAAA,MACP,eAAe,CAAC,SAAS,SAAS,QAAQ,MAAS;AAAA,MACnD;AAAA,MACA,eAAe,SAAS;AAAA,MACxB,oBAAoB,KAAK;AAAA;AAAA,EAC3B;AAEJ;AAEA,SAAS,oBAAoB;AAAA,EAC3B;AAAA,EACA;AACF,GAGG;AACD,QAAM,IAAI,KAAK;AACf,QAAM,WAAW,OAAO,KAAK,YAAY,uBAAuB,WAAW,IAAI,WAAW,qBAAqB;AAC/G,SACE,oBAAC,SAAI,WAAU,0FACb,+BAAC,SAAI,WAAU,aACb;AAAA,wBAAC,WAAM,WAAU,6CACd,YAAE,wCAAwC,iBAAiB,GAC9D;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,OAAO,YAAY;AAAA,QACnB,eAAe,CAAC,SAAS,SAAS,EAAE,oBAAoB,SAAS,qBAAqB,SAAY,KAAK,CAAC;AAAA,QAExG;AAAA,8BAAC,iBAAc,MAAK,MAClB,8BAAC,eAAY,GACf;AAAA,UACA,qBAAC,iBAAc,OAAM,SACnB;AAAA,gCAAC,cAAW,OAAO,oBAChB,YAAE,4CAA4C,wBAAwB,GACzE;AAAA,YACC,gBAAgB,IAAI,CAAC,YACpB,qBAAC,cAAuD,OAAO,QAAQ,MACrE;AAAA,kCAAC,qBACC,8BAAC,UAAK,WAAU,0BAA0B,kBAAQ,MAAK,GACzD;AAAA,cACA,oBAAC,UAAK,WAAU,mBAAmB,kBAAQ,OAAM;AAAA,cACjD,oBAAC,UAAK,WAAU,mDAAmD,kBAAQ,UAAS;AAAA,iBALrE,GAAG,QAAQ,IAAI,IAAI,QAAQ,QAAQ,EAMpD,CACD;AAAA,aACH;AAAA;AAAA;AAAA,IACF;AAAA,IACA,oBAAC,OAAE,WAAU,iCACV;AAAA,MACC;AAAA,MACA;AAAA,IACF,GACF;AAAA,KACF,GACF;AAEJ;AAEA,cAAc,SAAS,SAAS;AAAA,EAC9B,OAAO,CAAC,UAAU,oBAAC,mBAAiB,GAAG,OAAO;AAAA,EAC9C,sBAAsB;AAAA,EACtB,WAAW,CAAC,UAAU,oBAAC,uBAAqB,GAAG,OAAO;AACxD,CAAC;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -10,6 +10,9 @@ class FieldRegistryImpl {
|
|
|
10
10
|
getInput(kind) {
|
|
11
11
|
return this.map.get(kind.toLowerCase())?.input;
|
|
12
12
|
}
|
|
13
|
+
inputRendersOwnError(kind) {
|
|
14
|
+
return this.map.get(kind.toLowerCase())?.inputRendersOwnError === true;
|
|
15
|
+
}
|
|
13
16
|
getDefEditor(kind) {
|
|
14
17
|
return this.map.get(kind.toLowerCase())?.defEditor;
|
|
15
18
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/fields/registry.ts"],
|
|
4
|
-
"sourcesContent": ["import * as React from 'react'\nimport type { CrudCustomFieldRenderProps } from '../CrudForm'\n\nexport type FieldInputComponent = (props: CrudCustomFieldRenderProps & { def?: any }) => React.ReactNode\nexport type FieldDefEditorComponent = (props: { def: any; onChange: (patch: any) => void }) => React.ReactNode\n\ntype Entry = {
|
|
5
|
-
"mappings": "
|
|
4
|
+
"sourcesContent": ["import * as React from 'react'\nimport type { CrudCustomFieldRenderProps } from '../CrudForm'\n\nexport type FieldInputComponent = (props: CrudCustomFieldRenderProps & { def?: any }) => React.ReactNode\nexport type FieldDefEditorComponent = (props: { def: any; onChange: (patch: any) => void }) => React.ReactNode\n\ntype Entry = {\n input?: FieldInputComponent\n defEditor?: FieldDefEditorComponent\n inputRendersOwnError?: boolean\n}\n\nclass FieldRegistryImpl {\n private map = new Map<string, Entry>()\n\n register(kind: string, entry: Entry) {\n const k = kind.toLowerCase()\n const prev = this.map.get(k) || {}\n this.map.set(k, { ...prev, ...entry })\n }\n\n getInput(kind: string): FieldInputComponent | undefined {\n return this.map.get(kind.toLowerCase())?.input\n }\n\n inputRendersOwnError(kind: string): boolean {\n return this.map.get(kind.toLowerCase())?.inputRendersOwnError === true\n }\n\n getDefEditor(kind: string): FieldDefEditorComponent | undefined {\n return this.map.get(kind.toLowerCase())?.defEditor\n }\n}\n\nexport const FieldRegistry = new FieldRegistryImpl()\n\n// Placeholder for generator to auto-register fields from modules\n// eslint-disable-next-line @typescript-eslint/no-empty-function\nexport async function loadGeneratedFieldRegistrations(): Promise<void> {\n try {\n const gen = await import('./registry.generated')\n if (typeof gen.loadAll === 'function') gen.loadAll()\n } catch {\n // ignore when not present\n }\n}\n"],
|
|
5
|
+
"mappings": "AAYA,MAAM,kBAAkB;AAAA,EAAxB;AACE,SAAQ,MAAM,oBAAI,IAAmB;AAAA;AAAA,EAErC,SAAS,MAAc,OAAc;AACnC,UAAM,IAAI,KAAK,YAAY;AAC3B,UAAM,OAAO,KAAK,IAAI,IAAI,CAAC,KAAK,CAAC;AACjC,SAAK,IAAI,IAAI,GAAG,EAAE,GAAG,MAAM,GAAG,MAAM,CAAC;AAAA,EACvC;AAAA,EAEA,SAAS,MAA+C;AACtD,WAAO,KAAK,IAAI,IAAI,KAAK,YAAY,CAAC,GAAG;AAAA,EAC3C;AAAA,EAEA,qBAAqB,MAAuB;AAC1C,WAAO,KAAK,IAAI,IAAI,KAAK,YAAY,CAAC,GAAG,yBAAyB;AAAA,EACpE;AAAA,EAEA,aAAa,MAAmD;AAC9D,WAAO,KAAK,IAAI,IAAI,KAAK,YAAY,CAAC,GAAG;AAAA,EAC3C;AACF;AAEO,MAAM,gBAAgB,IAAI,kBAAkB;AAInD,eAAsB,kCAAiD;AACrE,MAAI;AACF,UAAM,MAAM,MAAM,OAAO,sBAAsB;AAC/C,QAAI,OAAO,IAAI,YAAY,WAAY,KAAI,QAAQ;AAAA,EACrD,QAAQ;AAAA,EAER;AACF;",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -54,11 +54,31 @@ class UnauthorizedError extends Error {
|
|
|
54
54
|
this.name = "UnauthorizedError";
|
|
55
55
|
}
|
|
56
56
|
}
|
|
57
|
+
const SESSION_REFRESH_ATTEMPT_KEY_PREFIX = "om:session-refresh-attempt:";
|
|
58
|
+
const SESSION_REFRESH_COOLDOWN_MS = 1e4;
|
|
59
|
+
function recentlyAttemptedSessionRefresh(target) {
|
|
60
|
+
try {
|
|
61
|
+
const raw = window.sessionStorage.getItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target);
|
|
62
|
+
if (!raw) return false;
|
|
63
|
+
const attemptedAt = Number(raw);
|
|
64
|
+
return Number.isFinite(attemptedAt) && Date.now() - attemptedAt < SESSION_REFRESH_COOLDOWN_MS;
|
|
65
|
+
} catch {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
function recordSessionRefreshAttempt(target) {
|
|
70
|
+
try {
|
|
71
|
+
window.sessionStorage.setItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target, String(Date.now()));
|
|
72
|
+
} catch {
|
|
73
|
+
}
|
|
74
|
+
}
|
|
57
75
|
function redirectToSessionRefresh() {
|
|
58
76
|
if (typeof window === "undefined") return;
|
|
59
77
|
const current = window.location.pathname + window.location.search;
|
|
60
78
|
if (window.location.pathname.startsWith("/api/auth")) return;
|
|
61
79
|
if (/\/[^/]+\/portal(\/|$)/.test(window.location.pathname)) return;
|
|
80
|
+
if (recentlyAttemptedSessionRefresh(current)) return;
|
|
81
|
+
recordSessionRefreshAttempt(current);
|
|
62
82
|
try {
|
|
63
83
|
flash("Session expired. Redirecting to sign in\u2026", "warning");
|
|
64
84
|
setTimeout(() => {
|
|
@@ -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\nfunction isLoginPathname(pathname: string): boolean {\n return pathname.startsWith('/login')\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\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 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;AAEA,SAAS,gBAAgB,UAA2B;AAClD,SAAO,SAAS,WAAW,QAAQ;AACrC;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;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;AACF,UAAM,iDAA4C,SAAS;AAC3D,eAAW,MAAM;AACf,aAAO,SAAS,OAAO,sCAAsC,mBAAmB,OAAO,CAAC;AAAA,IAC1F,GAAG,EAAE;AAAA,EACP,QAAQ;AAAA,EAER;AACF;AAEO,MAAM,uBAAuB,MAAM;AAAA,EAKxC,YACE,UAAU,aACV,SACA;AACA,UAAM,OAAO;AARf,SAAS,SAAS;AAShB,SAAK,OAAO;AACZ,SAAK,mBAAmB,SAAS,kBAAkB,SAAS,CAAC,GAAG,QAAQ,gBAAgB,IAAI;AAC5F,SAAK,gBAAgB,SAAS,eAAe,SAAS,CAAC,GAAG,QAAQ,aAAa,IAAI;AAAA,EACrF;AACF;AAEA,IAAI,0BAAoC,CAAC,OAAO;AAEzC,SAAS,sBAAsB,KAAoD;AACxF,MAAI,KAAK,yBAAyB,IAAI,sBAAsB,QAAQ;AAClE,8BAA0B,CAAC,GAAG,IAAI,qBAAqB,EAAE,IAAI,MAAM;AAAA,EACrE;AACF;AAEA,SAAS,6BAA6B,SAA2F;AAC/H,QAAM,WAAW,SAAS,kBAAkB,OAAO,OAAO,KAAK,CAAC;AAChE,QAAM,QAAQ,SAAS,eAAe,OAAO,OAAO,KAAK,CAAC;AAC1D,QAAM,iBAAiB,MAAM,SAAS,QAAQ,wBAAwB,OAAO,OAAO;AACpF,MAAI,SAAS,QAAQ;AACnB,WAAO,2DAA2D,SAAS,KAAK,IAAI,CAAC;AAAA,EACvF;AACA,MAAI,eAAe,QAAQ;AACzB,WAAO,+CAA+C,eAAe,KAAK,IAAI,CAAC;AAAA,EACjF;AACA,SAAO;AACT;AAWO,SAAS,sBAAsB,SAAmF;AACvH,MAAI,OAAO,WAAW,YAAa;AAEnC,MAAI,wBAAwB,KAAK,OAAO,SAAS,QAAQ,EAAG;AAC5D,MAAI;AACF,UAAM,6BAA6B,OAAO,GAAG,SAAS;AAAA,EACxD,QAAQ;AAAA,EAER;AACF;AAQO,MAAM,2BAA2B;AAExC,eAAsB,SAAS,OAA0B,MAAuC;AAE9F,QAAM,gBACJ,OAAO,WAAW,cACb,OAAsD,oBACvD;AACN,QAAM,gBAAiB,WAAyD;AAChF,QAAM,YAAY,iBAAiB;AACnC,MAAI,CAAC,WAAW;AACd,WAAO,IAAI;AAAA,MACT,KAAK,UAAU,EAAE,OAAO,6CAA6C,CAAC;AAAA,MACtE,EAAE,QAAQ,KAAK,SAAS,EAAE,gBAAgB,mBAAmB,EAAE;AAAA,IACjE;AAAA,EACF;AACA,QAAM,SAAS,cAAc,qBAAqB;AAClD,QAAM,WAAwB,OAAO,KAAK,MAAM,EAAE,SAC9C,EAAE,GAAI,QAAQ,CAAC,GAAI,SAAS,aAAa,MAAM,SAAS,MAAM,EAAE,IAChE,QAAQ,CAAC;AAIb,QAAM,aAA0B,SAAS,cACrC,WACA,oBAAoB,KAAK,IACvB,EAAE,GAAG,UAAU,aAAa,UAAU,IACtC;AACN,QAAM,iBAAiB,IAAI,QAAQ,YAAY,OAAO;AACtD,QAAM,8BAA8B,qBAAqB,gBAAgB,4BAA4B;AACrG,QAAM,2BAA2B,qBAAqB,gBAAgB,yBAAyB;AAK/F,QAAM,kBAAkB,aAAa;AACrC,QAAM,MAAM,MAAM,UAAU,OAAO,UAAU;AAC7C,QAAM,mBAAmB,aAAa;AACtC,QAAM,cAAc,gBAAgB,eAAe,KAAK,gBAAgB,gBAAgB;AACxF,QAAM,gBAAgB,iBAAiB,eAAe,KAAK,iBAAiB,gBAAgB;AAC5F,MAAI,IAAI,WAAW,KAAK;AAGtB,QAAI,CAAC,eAAe,CAAC,iBAAiB,CAAC,6BAA6B;AAClE,+BAAyB;AAEzB,YAAM,IAAI,kBAAkB,MAAM,IAAI,KAAK,EAAE,MAAM,MAAM,cAAc,CAAC;AAAA,IAC1E;AACA,WAAO;AAAA,EACT;AACA,MAAI,IAAI,WAAW,KAAK;AAEtB,QAAI,QAAyB;AAC7B,QAAI,WAA4B;AAChC,QAAI,UAAmB;AACvB,UAAM,UAAU,MAAM,aAAsC,IAAI,MAAM,GAAG,IAAI;AAC7E,QAAI,WAAW,OAAO,YAAY,UAAU;AAC1C,UAAI,MAAM,QAAQ,QAAQ,aAAa,GAAG;AACxC,gBAAQ,QAAQ,cAAc,IAAI,CAAC,MAAM,OAAO,CAAC,CAAC;AAAA,MACpD;AACA,UAAI,MAAM,QAAQ,QAAQ,gBAAgB,GAAG;AAC3C,mBAAW,QAAQ,iBAAiB,IAAI,CAAC,MAAM,OAAO,CAAC,CAAC;AAAA,MAC1D;AACA,gBAAU;AAAA,IACZ;AAEA,QAAI,CAAC,eAAe,CAAC,iBAAiB,CAAC,0BAA0B;AAC/D,YAAM,SACJ,OAAO,UAAU,WACb,QACA,iBAAiB,MACf,MAAM,SAAS,IACd,OAAO,YAAY,eAAe,iBAAiB,UAClD,MAAM,MACN;AACV,UAAI;AACF,eAAO,KAAK,sBAAsB;AAAA,UAChC,KAAK;AAAA,UACL,QAAQ,IAAI;AAAA,UACZ,eAAe;AAAA,UACf,kBAAkB;AAAA,UAClB,SAAS;AAAA,QACX,CAAC;AAAA,MACH,QAAQ;AAAA,MAAC;AACT,YAAM,cAAc,QAAS,SAAS,MAAM,UAAY,YAAY,SAAS,MAAO;AACpF,UAAI,aAAa;AACf,8BAAsB,EAAE,eAAe,OAAO,kBAAkB,SAAS,CAAC;AAAA,MAC5E;AACA,UAAI,MAAM;AACV,UAAI,WAAW,OAAO,YAAY,UAAU;AAC1C,YAAI,OAAO,QAAQ,UAAU,UAAU;AACrC,gBAAM,QAAQ;AAAA,QAChB,WAAW,OAAO,QAAQ,YAAY,UAAU;AAC9C,gBAAM,QAAQ;AAAA,QAChB;AAAA,MACF,OAAO;AACL,cAAM,MAAM,IAAI,MAAM,EAAE,KAAK,EAAE,MAAM,MAAM,WAAW;AAAA,MACxD;AAGA,YAAM,IAAI,eAAe,KAAK,EAAE,kBAAkB,UAAU,eAAe,MAAM,CAAC;AAAA,IACpF;AAAA,EAEF;AACA,MAAI;AACF,UAAM,SAAS,IAAI,QAAQ,IAAI,gBAAgB;AAC/C,UAAM,WAAW,6BAA6B,MAAM;AACpD,QAAI,SAAU,eAAc,QAAQ;AAAA,EACtC,QAAQ;AAAA,EAER;AACA,MAAI;AACF,UAAM,aAAa,IAAI,QAAQ,IAAI,oBAAoB;AACvD,QAAI,YAAY;AACd,YAAM,SAAS,KAAK,MAAM,UAAU;AACpC,UAAI,UAAU,OAAO,WAAW,YAAY,OAAO,SAAS,iBAAiB;AAC3E,cAAM,SAAS,OAAO,OAAO,WAAW,WAAW,OAAO,SAAS,OAAO,OAAO,UAAU,EAAE;AAC7F,YAAI,QAAQ;AACV,gBAAM,YAAY,OAAO,OAAO,cAAc,WAAW,OAAO,YAAY;AAC5E,gBAAM,eAAe,OAAO,OAAO,iBAAiB,WAAW,OAAO,eAAe;AACrF,gBAAM,QAAQ,OAAO,UAAU,WAAW,WAAW;AACrD,gBAAM,cACJ,OAAO,OAAO,gBAAgB,YAAY,OAAO,YAAY,KAAK,IAC9D,OAAO,YAAY,KAAK,IACxB;AACN,kCAAwB,EAAE,QAAQ,aAAa,WAAW,cAAc,MAAM,CAAC;AAAA,QACjF;AAAA,MACF;AAAA,IACF;AAAA,EACF,QAAQ;AAAA,EAER;AACA,SAAO;AACT;",
|
|
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\nfunction isLoginPathname(pathname: string): boolean {\n return pathname.startsWith('/login')\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": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
|
@@ -110,6 +110,7 @@ function buildFormFieldFromCustomFieldDef(def, opts) {
|
|
|
110
110
|
label,
|
|
111
111
|
type: "custom",
|
|
112
112
|
...baseProps,
|
|
113
|
+
...FieldRegistry.inputRendersOwnError(def.kind) ? { rendersOwnError: true } : {},
|
|
113
114
|
component: (props) => input({ ...props, def })
|
|
114
115
|
};
|
|
115
116
|
}
|
|
@@ -164,6 +165,7 @@ function buildFormFieldFromCustomFieldDef(def, opts) {
|
|
|
164
165
|
label,
|
|
165
166
|
type: "custom",
|
|
166
167
|
...baseProps,
|
|
168
|
+
...FieldRegistry.inputRendersOwnError(def.kind) ? { rendersOwnError: true } : {},
|
|
167
169
|
component: (props) => input({ ...props, def })
|
|
168
170
|
};
|
|
169
171
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../../src/backend/utils/customFieldForms.ts"],
|
|
4
|
-
"sourcesContent": ["import type { CrudField } from '../CrudForm'\nimport type {\n CustomFieldDefDto,\n CustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport {\n filterCustomFieldDefs,\n fetchCustomFieldDefs,\n fetchCustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'\nimport { fetchAllOffsetPages } from './fetchAllPages'\nimport { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'\nimport { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'\n\nlet registryReady: Promise<void> | null = null\n\nasync function ensureFieldRegistryReady() {\n if (!registryReady) {\n registryReady = loadGeneratedFieldRegistrations().catch((err) => {\n registryReady = null\n throw err\n })\n }\n await registryReady\n}\n\nfunction buildOptionsUrl(base: string, query?: string): string {\n if (!query) return base\n try {\n const isAbsolute = /^([a-z][a-z\\d+\\-.]*:)?\\/\\//i.test(base)\n const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n const url = isAbsolute ? new URL(base) : new URL(base, origin)\n if (!url.searchParams.has('query')) url.searchParams.append('query', query)\n if (!url.searchParams.has('q')) url.searchParams.append('q', query)\n if (isAbsolute) return url.toString()\n return `${url.pathname}${url.search}`\n } catch {\n const sep = base.includes('?') ? '&' : '?'\n if (base.includes('query=')) return `${base}${sep}q=${encodeURIComponent(query)}`\n return `${base}${sep}query=${encodeURIComponent(query)}`\n }\n}\n\nasync function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {\n try {\n // Options routes bound how many rows one response carries (dictionary\n // entries at 500), so walk every page instead of reading a single\n // response \u2014 a stored value past the first page must stay selectable and\n // resolvable to its label. Routes without `hasMore` still resolve in one\n // request.\n const pages = await fetchAllOffsetPages(url)\n if (!pages.ok) return []\n return pages.items.map((item) => {\n const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : null\n return {\n value: String(record?.value ?? item),\n label: String(record?.label ?? record?.value ?? item),\n }\n })\n } catch {\n return []\n }\n}\n\nexport function buildFormFieldFromCustomFieldDef(\n def: CustomFieldDefDto,\n opts?: { bareIds?: boolean }\n): CrudField | null {\n const id = opts?.bareIds ? def.key : `cf_${def.key}`\n const label = def.label || def.key\n const required = Array.isArray((def as any).validation)\n ? ((def as any).validation as any[]).some((rule) => rule && rule.rule === 'required')\n : false\n const baseProps = {\n description: def.description,\n required,\n ...(def.defaultValue !== undefined && def.defaultValue !== null ? { defaultValue: def.defaultValue } : {}),\n }\n\n switch (def.kind) {\n case 'boolean':\n return { id, label, type: 'checkbox', ...baseProps }\n case 'integer':\n case 'float':\n return { id, label, type: 'number', ...baseProps }\n case 'date':\n return { id, label, type: 'datepicker', ...baseProps }\n case 'datetime':\n return { id, label, type: 'datetime', ...baseProps }\n case 'multiline': {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n case 'select':\n case 'currency':\n case 'relation':\n return {\n id,\n label,\n type: 'select',\n ...baseProps,\n options: normalizeCustomFieldOptions(def.options || []).map((option) => ({\n value: option.value,\n label: option.label,\n })),\n multiple: !!def.multi,\n ...(def.kind === 'currency'\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(CURRENCY_OPTIONS_URL, query)\n return loadRemoteOptions(url)\n },\n }\n : def.optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n ...(def.multi && def.input === 'listbox' ? ({ listbox: true } as any) : {}),\n }\n case 'dictionary': {\n if (!def.multi) {\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n const optionsUrl = def.optionsUrl || (def.dictionaryId ? `/api/dictionaries/${def.dictionaryId}/entries` : undefined)\n return {\n id,\n label,\n type: 'select',\n description: def.description,\n required,\n options: [],\n multiple: true,\n listbox: true,\n ...(optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(optionsUrl, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n }\n }\n default: {\n if (def.kind === 'text' && def.multi) {\n const base: any = { id, label, type: 'tags', ...baseProps }\n const resolvedOptions = normalizeCustomFieldOptions(def.options || [])\n if (resolvedOptions.length > 0) {\n base.options = resolvedOptions.map((option) => ({ value: option.value, label: option.label }))\n }\n if (def.optionsUrl) {\n base.loadOptions = async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n }\n }\n return base\n }\n if (def.kind === 'text' && typeof def.editor === 'string' && def.editor) {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n }\n}\n\nexport function buildFormFieldsFromCustomFields(\n defs: CustomFieldDefDto[],\n opts?: { bareIds?: boolean }\n): CrudField[] {\n const fields: CrudField[] = []\n const visible = filterCustomFieldDefs(defs, 'form')\n const seenKeys = new Set<string>()\n for (const def of visible) {\n const keyLower = String(def.key).toLowerCase()\n if (seenKeys.has(keyLower)) continue\n seenKeys.add(keyLower)\n const field = buildFormFieldFromCustomFieldDef(def, opts)\n if (field) fields.push(field)\n }\n return fields\n}\n\nexport async function fetchCustomFieldFormStructure(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n await ensureFieldRegistryReady()\n const metadata = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl)\n const definitions = Array.isArray(metadata.items) ? metadata.items : []\n const fields = buildFormFieldsFromCustomFields(definitions, options)\n return { fields, definitions, metadata }\n}\n\nexport async function fetchCustomFieldFormFields(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<CrudField[]> {\n const { fields } = await fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n return fields\n}\n\nexport async function fetchCustomFieldFormFieldsWithDefinitions(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n return fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n}\n"],
|
|
5
|
-
"mappings": "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",
|
|
4
|
+
"sourcesContent": ["import type { CrudField } from '../CrudForm'\nimport type {\n CustomFieldDefDto,\n CustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport {\n filterCustomFieldDefs,\n fetchCustomFieldDefs,\n fetchCustomFieldDefinitionsPayload,\n} from './customFieldDefs'\nimport { FieldRegistry, loadGeneratedFieldRegistrations } from '../fields/registry'\nimport { fetchAllOffsetPages } from './fetchAllPages'\nimport { normalizeCustomFieldOptions } from '@open-mercato/shared/modules/entities/options'\nimport { CURRENCY_OPTIONS_URL } from '@open-mercato/shared/modules/entities/kinds'\n\nlet registryReady: Promise<void> | null = null\n\nasync function ensureFieldRegistryReady() {\n if (!registryReady) {\n registryReady = loadGeneratedFieldRegistrations().catch((err) => {\n registryReady = null\n throw err\n })\n }\n await registryReady\n}\n\nfunction buildOptionsUrl(base: string, query?: string): string {\n if (!query) return base\n try {\n const isAbsolute = /^([a-z][a-z\\d+\\-.]*:)?\\/\\//i.test(base)\n const origin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost'\n const url = isAbsolute ? new URL(base) : new URL(base, origin)\n if (!url.searchParams.has('query')) url.searchParams.append('query', query)\n if (!url.searchParams.has('q')) url.searchParams.append('q', query)\n if (isAbsolute) return url.toString()\n return `${url.pathname}${url.search}`\n } catch {\n const sep = base.includes('?') ? '&' : '?'\n if (base.includes('query=')) return `${base}${sep}q=${encodeURIComponent(query)}`\n return `${base}${sep}query=${encodeURIComponent(query)}`\n }\n}\n\nasync function loadRemoteOptions(url: string): Promise<Array<{ value: string; label: string }>> {\n try {\n // Options routes bound how many rows one response carries (dictionary\n // entries at 500), so walk every page instead of reading a single\n // response \u2014 a stored value past the first page must stay selectable and\n // resolvable to its label. Routes without `hasMore` still resolve in one\n // request.\n const pages = await fetchAllOffsetPages(url)\n if (!pages.ok) return []\n return pages.items.map((item) => {\n const record = item && typeof item === 'object' ? (item as Record<string, unknown>) : null\n return {\n value: String(record?.value ?? item),\n label: String(record?.label ?? record?.value ?? item),\n }\n })\n } catch {\n return []\n }\n}\n\nexport function buildFormFieldFromCustomFieldDef(\n def: CustomFieldDefDto,\n opts?: { bareIds?: boolean }\n): CrudField | null {\n const id = opts?.bareIds ? def.key : `cf_${def.key}`\n const label = def.label || def.key\n const required = Array.isArray((def as any).validation)\n ? ((def as any).validation as any[]).some((rule) => rule && rule.rule === 'required')\n : false\n const baseProps = {\n description: def.description,\n required,\n ...(def.defaultValue !== undefined && def.defaultValue !== null ? { defaultValue: def.defaultValue } : {}),\n }\n\n switch (def.kind) {\n case 'boolean':\n return { id, label, type: 'checkbox', ...baseProps }\n case 'integer':\n case 'float':\n return { id, label, type: 'number', ...baseProps }\n case 'date':\n return { id, label, type: 'datepicker', ...baseProps }\n case 'datetime':\n return { id, label, type: 'datetime', ...baseProps }\n case 'multiline': {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n case 'select':\n case 'currency':\n case 'relation':\n return {\n id,\n label,\n type: 'select',\n ...baseProps,\n options: normalizeCustomFieldOptions(def.options || []).map((option) => ({\n value: option.value,\n label: option.label,\n })),\n multiple: !!def.multi,\n ...(def.kind === 'currency'\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(CURRENCY_OPTIONS_URL, query)\n return loadRemoteOptions(url)\n },\n }\n : def.optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n ...(def.multi && def.input === 'listbox' ? ({ listbox: true } as any) : {}),\n }\n case 'dictionary': {\n if (!def.multi) {\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n ...(FieldRegistry.inputRendersOwnError(def.kind) ? { rendersOwnError: true } : {}),\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n const optionsUrl = def.optionsUrl || (def.dictionaryId ? `/api/dictionaries/${def.dictionaryId}/entries` : undefined)\n return {\n id,\n label,\n type: 'select',\n description: def.description,\n required,\n options: [],\n multiple: true,\n listbox: true,\n ...(optionsUrl\n ? {\n loadOptions: async (query?: string) => {\n const url = buildOptionsUrl(optionsUrl, query)\n return loadRemoteOptions(url)\n },\n }\n : {}),\n }\n }\n default: {\n if (def.kind === 'text' && def.multi) {\n const base: any = { id, label, type: 'tags', ...baseProps }\n const resolvedOptions = normalizeCustomFieldOptions(def.options || [])\n if (resolvedOptions.length > 0) {\n base.options = resolvedOptions.map((option) => ({ value: option.value, label: option.label }))\n }\n if (def.optionsUrl) {\n base.loadOptions = async (query?: string) => {\n const url = buildOptionsUrl(def.optionsUrl!, query)\n return loadRemoteOptions(url)\n }\n }\n return base\n }\n if (def.kind === 'text' && typeof def.editor === 'string' && def.editor) {\n if (def.editor === 'plain') {\n return { id, label, type: 'textarea', ...baseProps }\n }\n let editor: 'simple' | 'uiw' | 'html' = 'html'\n if (def.editor === 'simpleMarkdown') editor = 'simple'\n else if (def.editor === 'htmlRichText') editor = 'html'\n return { id, label, type: 'richtext', ...baseProps, editor }\n }\n const input = FieldRegistry.getInput(def.kind)\n if (input) {\n return {\n id,\n label,\n type: 'custom',\n ...baseProps,\n ...(FieldRegistry.inputRendersOwnError(def.kind) ? { rendersOwnError: true } : {}),\n component: (props) => input({ ...props, def }),\n }\n }\n return { id, label, type: 'text', ...baseProps }\n }\n }\n}\n\nexport function buildFormFieldsFromCustomFields(\n defs: CustomFieldDefDto[],\n opts?: { bareIds?: boolean }\n): CrudField[] {\n const fields: CrudField[] = []\n const visible = filterCustomFieldDefs(defs, 'form')\n const seenKeys = new Set<string>()\n for (const def of visible) {\n const keyLower = String(def.key).toLowerCase()\n if (seenKeys.has(keyLower)) continue\n seenKeys.add(keyLower)\n const field = buildFormFieldFromCustomFieldDef(def, opts)\n if (field) fields.push(field)\n }\n return fields\n}\n\nexport async function fetchCustomFieldFormStructure(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n await ensureFieldRegistryReady()\n const metadata = await fetchCustomFieldDefinitionsPayload(entityIds, fetchImpl)\n const definitions = Array.isArray(metadata.items) ? metadata.items : []\n const fields = buildFormFieldsFromCustomFields(definitions, options)\n return { fields, definitions, metadata }\n}\n\nexport async function fetchCustomFieldFormFields(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<CrudField[]> {\n const { fields } = await fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n return fields\n}\n\nexport async function fetchCustomFieldFormFieldsWithDefinitions(\n entityIds: string | string[],\n fetchImpl?: typeof fetch,\n options?: { bareIds?: boolean },\n): Promise<{ fields: CrudField[]; definitions: CustomFieldDefDto[]; metadata: CustomFieldDefinitionsPayload }> {\n return fetchCustomFieldFormStructure(entityIds, fetchImpl, options)\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.8-develop.
|
|
3
|
+
"version": "0.6.8-develop.6924.1.a8d208fcdc",
|
|
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.6.8-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.8-develop.6924.1.a8d208fcdc",
|
|
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.6.8-develop.
|
|
165
|
+
"@open-mercato/shared": "0.6.8-develop.6924.1.a8d208fcdc",
|
|
166
166
|
"@testing-library/dom": "^10.4.1",
|
|
167
167
|
"@testing-library/jest-dom": "^7.0.0",
|
|
168
168
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/CrudForm.tsx
CHANGED
|
@@ -279,6 +279,7 @@ export type CrudCustomFieldRenderProps = {
|
|
|
279
279
|
export type CrudCustomField = CrudFieldBase & {
|
|
280
280
|
type: 'custom'
|
|
281
281
|
component: (props: CrudCustomFieldRenderProps) => React.ReactNode
|
|
282
|
+
rendersOwnError?: boolean
|
|
282
283
|
}
|
|
283
284
|
|
|
284
285
|
export type CrudField = CrudBuiltinField | CrudCustomField
|
|
@@ -4592,7 +4593,9 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4592
4593
|
<div>{field.description}</div>
|
|
4593
4594
|
</div>
|
|
4594
4595
|
) : null}
|
|
4595
|
-
{error
|
|
4596
|
+
{error && !(field.type === 'custom' && field.rendersOwnError) ? (
|
|
4597
|
+
<div className="text-xs text-status-error-text">{error}</div>
|
|
4598
|
+
) : null}
|
|
4596
4599
|
</div>
|
|
4597
4600
|
)
|
|
4598
4601
|
},
|
|
@@ -4616,5 +4619,6 @@ const FieldControl = React.memo(function FieldControlImpl({
|
|
|
4616
4619
|
prev.recordId === next.recordId &&
|
|
4617
4620
|
(prev.field.type !== 'custom' ||
|
|
4618
4621
|
(prev.values === next.values &&
|
|
4619
|
-
prev.field.component === (next.field as CrudCustomField).component
|
|
4622
|
+
prev.field.component === (next.field as CrudCustomField).component &&
|
|
4623
|
+
prev.field.rendersOwnError === (next.field as CrudCustomField).rendersOwnError))
|
|
4620
4624
|
)
|
|
@@ -25,6 +25,58 @@ import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWith
|
|
|
25
25
|
import { CrudForm, type CrudField } from '../CrudForm'
|
|
26
26
|
|
|
27
27
|
describe('CrudForm validation state', () => {
|
|
28
|
+
it('renders each custom field error through exactly one declared owner', async () => {
|
|
29
|
+
const fields: CrudField[] = [
|
|
30
|
+
{
|
|
31
|
+
id: 'wrapperOwned',
|
|
32
|
+
label: 'Wrapper owned',
|
|
33
|
+
type: 'custom',
|
|
34
|
+
required: true,
|
|
35
|
+
component: () => <input aria-label="Wrapper owned" />,
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
id: 'componentOwned',
|
|
39
|
+
label: 'Component owned',
|
|
40
|
+
type: 'custom',
|
|
41
|
+
required: true,
|
|
42
|
+
rendersOwnError: true,
|
|
43
|
+
component: ({ error }) => (
|
|
44
|
+
<div>{error ? <span data-testid="component-owned-error">{error}</span> : null}</div>
|
|
45
|
+
),
|
|
46
|
+
},
|
|
47
|
+
]
|
|
48
|
+
|
|
49
|
+
const { container, getByTestId } = renderWithProviders(
|
|
50
|
+
<CrudForm title="Form" fields={fields} onSubmit={() => {}} />,
|
|
51
|
+
{
|
|
52
|
+
dict: {
|
|
53
|
+
'ui.forms.actions.save': 'Save',
|
|
54
|
+
'ui.forms.errors.highlighted': 'Please fix the highlighted errors.',
|
|
55
|
+
'ui.forms.errors.required': 'This field is required',
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
const form = container.querySelector('form')
|
|
61
|
+
const wrapperOwnedField = container.querySelector('[data-crud-field-id="wrapperOwned"]')
|
|
62
|
+
const componentOwnedField = container.querySelector('[data-crud-field-id="componentOwned"]')
|
|
63
|
+
|
|
64
|
+
expect(form).not.toBeNull()
|
|
65
|
+
expect(wrapperOwnedField).not.toBeNull()
|
|
66
|
+
expect(componentOwnedField).not.toBeNull()
|
|
67
|
+
|
|
68
|
+
await act(async () => {
|
|
69
|
+
fireEvent.submit(form as HTMLFormElement)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(wrapperOwnedField?.querySelector('.text-xs.text-status-error-text')).toHaveTextContent('This field is required')
|
|
74
|
+
expect(getByTestId('component-owned-error')).toHaveTextContent('This field is required')
|
|
75
|
+
})
|
|
76
|
+
|
|
77
|
+
expect(componentOwnedField?.querySelector('.text-xs.text-status-error-text')).toBeNull()
|
|
78
|
+
})
|
|
79
|
+
|
|
28
80
|
it('clears corrected field errors immediately without dropping unrelated errors', async () => {
|
|
29
81
|
const fields: CrudField[] = [
|
|
30
82
|
{ id: 'name', label: 'Name', type: 'text', required: true },
|
|
@@ -5,7 +5,7 @@ import { FieldRegistry } from '../fields/registry'
|
|
|
5
5
|
describe('buildFormFieldsFromCustomFields', () => {
|
|
6
6
|
beforeAll(() => {
|
|
7
7
|
FieldRegistry.register('dictionary', { input: () => null })
|
|
8
|
-
FieldRegistry.register('phone', { input: () => null })
|
|
8
|
+
FieldRegistry.register('phone', { input: () => null, inputRendersOwnError: true })
|
|
9
9
|
})
|
|
10
10
|
|
|
11
11
|
it('maps kinds to CrudField and filters by formEditable', () => {
|
|
@@ -69,6 +69,8 @@ describe('buildFormFieldsFromCustomFields', () => {
|
|
|
69
69
|
}
|
|
70
70
|
// Phone resolves to a custom input via the field registry
|
|
71
71
|
expect(byId['cf_work_phone']?.type).toBe('custom')
|
|
72
|
+
expect(byId['cf_work_phone']?.rendersOwnError).toBe(true)
|
|
73
|
+
expect(byId['cf_region']?.rendersOwnError).toBeUndefined()
|
|
72
74
|
// Multiline now defaults to richtext (markdown editor)
|
|
73
75
|
expect(byId['cf_notes']?.type).toBe('richtext')
|
|
74
76
|
expect(byId['cf_desc']?.type).toBe('richtext')
|
|
@@ -54,6 +54,7 @@ describe('phone custom field', () => {
|
|
|
54
54
|
it('registers an input and a definition editor under the "phone" kind', () => {
|
|
55
55
|
expect(FieldRegistry.getInput('phone')).toBeDefined()
|
|
56
56
|
expect(FieldRegistry.getDefEditor('phone')).toBeDefined()
|
|
57
|
+
expect(FieldRegistry.inputRendersOwnError('phone')).toBe(true)
|
|
57
58
|
})
|
|
58
59
|
|
|
59
60
|
it('wraps PhoneNumberField and forwards the configured default country', () => {
|
|
@@ -4,7 +4,11 @@ import type { CrudCustomFieldRenderProps } from '../CrudForm'
|
|
|
4
4
|
export type FieldInputComponent = (props: CrudCustomFieldRenderProps & { def?: any }) => React.ReactNode
|
|
5
5
|
export type FieldDefEditorComponent = (props: { def: any; onChange: (patch: any) => void }) => React.ReactNode
|
|
6
6
|
|
|
7
|
-
type Entry = {
|
|
7
|
+
type Entry = {
|
|
8
|
+
input?: FieldInputComponent
|
|
9
|
+
defEditor?: FieldDefEditorComponent
|
|
10
|
+
inputRendersOwnError?: boolean
|
|
11
|
+
}
|
|
8
12
|
|
|
9
13
|
class FieldRegistryImpl {
|
|
10
14
|
private map = new Map<string, Entry>()
|
|
@@ -19,6 +23,10 @@ class FieldRegistryImpl {
|
|
|
19
23
|
return this.map.get(kind.toLowerCase())?.input
|
|
20
24
|
}
|
|
21
25
|
|
|
26
|
+
inputRendersOwnError(kind: string): boolean {
|
|
27
|
+
return this.map.get(kind.toLowerCase())?.inputRendersOwnError === true
|
|
28
|
+
}
|
|
29
|
+
|
|
22
30
|
getDefEditor(kind: string): FieldDefEditorComponent | undefined {
|
|
23
31
|
return this.map.get(kind.toLowerCase())?.defEditor
|
|
24
32
|
}
|
|
@@ -45,6 +45,7 @@ describe('apiFetch', () => {
|
|
|
45
45
|
jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
46
46
|
jest.useFakeTimers()
|
|
47
47
|
window.history.pushState({}, '', '/backend/sales/documents')
|
|
48
|
+
window.sessionStorage.clear()
|
|
48
49
|
;(window as unknown as Record<string, unknown>).__omOriginalFetch = undefined
|
|
49
50
|
})
|
|
50
51
|
|
|
@@ -187,4 +188,18 @@ describe('apiFetch', () => {
|
|
|
187
188
|
'warning',
|
|
188
189
|
)
|
|
189
190
|
})
|
|
191
|
+
|
|
192
|
+
it('guards the session-refresh redirect from looping when a non-session 401 repeats on the same page (GH #5186)', async () => {
|
|
193
|
+
;(window as unknown as Record<string, unknown>).__omOriginalFetch = jest.fn(async () =>
|
|
194
|
+
createMockResponse(401, { error: 'Unauthorized' }),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
await expect(apiFetch('/api/private')).rejects.toBeInstanceOf(UnauthorizedError)
|
|
198
|
+
expect(flash).toHaveBeenCalledTimes(1)
|
|
199
|
+
|
|
200
|
+
// The refresh bounce lands back on the same page and the same endpoint
|
|
201
|
+
// answers 401 again — the second attempt must not re-trigger the redirect.
|
|
202
|
+
await expect(apiFetch('/api/private')).rejects.toBeInstanceOf(UnauthorizedError)
|
|
203
|
+
expect(flash).toHaveBeenCalledTimes(1)
|
|
204
|
+
})
|
|
190
205
|
})
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -67,6 +67,32 @@ export class UnauthorizedError extends Error {
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
const SESSION_REFRESH_ATTEMPT_KEY_PREFIX = 'om:session-refresh-attempt:'
|
|
71
|
+
const SESSION_REFRESH_COOLDOWN_MS = 10_000
|
|
72
|
+
|
|
73
|
+
// A genuinely expired session redirects once per target, then goes quiet: the
|
|
74
|
+
// refresh always succeeds (the session cookie is fine), bounces back to the
|
|
75
|
+
// same URL, and re-triggers the same 401 for any non-session cause — without
|
|
76
|
+
// this guard that bounce repeats forever (GH #5186).
|
|
77
|
+
function recentlyAttemptedSessionRefresh(target: string): boolean {
|
|
78
|
+
try {
|
|
79
|
+
const raw = window.sessionStorage.getItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target)
|
|
80
|
+
if (!raw) return false
|
|
81
|
+
const attemptedAt = Number(raw)
|
|
82
|
+
return Number.isFinite(attemptedAt) && Date.now() - attemptedAt < SESSION_REFRESH_COOLDOWN_MS
|
|
83
|
+
} catch {
|
|
84
|
+
return false
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function recordSessionRefreshAttempt(target: string): void {
|
|
89
|
+
try {
|
|
90
|
+
window.sessionStorage.setItem(SESSION_REFRESH_ATTEMPT_KEY_PREFIX + target, String(Date.now()))
|
|
91
|
+
} catch {
|
|
92
|
+
// no-op
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
70
96
|
export function redirectToSessionRefresh() {
|
|
71
97
|
if (typeof window === 'undefined') return
|
|
72
98
|
const current = window.location.pathname + window.location.search
|
|
@@ -74,6 +100,8 @@ export function redirectToSessionRefresh() {
|
|
|
74
100
|
if (window.location.pathname.startsWith('/api/auth')) return
|
|
75
101
|
// Portal routes have their own customer auth — never redirect to staff login
|
|
76
102
|
if (/\/[^/]+\/portal(\/|$)/.test(window.location.pathname)) return
|
|
103
|
+
if (recentlyAttemptedSessionRefresh(current)) return
|
|
104
|
+
recordSessionRefreshAttempt(current)
|
|
77
105
|
try {
|
|
78
106
|
flash('Session expired. Redirecting to sign in…', 'warning')
|
|
79
107
|
setTimeout(() => {
|
|
@@ -136,6 +136,7 @@ export function buildFormFieldFromCustomFieldDef(
|
|
|
136
136
|
label,
|
|
137
137
|
type: 'custom',
|
|
138
138
|
...baseProps,
|
|
139
|
+
...(FieldRegistry.inputRendersOwnError(def.kind) ? { rendersOwnError: true } : {}),
|
|
139
140
|
component: (props) => input({ ...props, def }),
|
|
140
141
|
}
|
|
141
142
|
}
|
|
@@ -192,6 +193,7 @@ export function buildFormFieldFromCustomFieldDef(
|
|
|
192
193
|
label,
|
|
193
194
|
type: 'custom',
|
|
194
195
|
...baseProps,
|
|
196
|
+
...(FieldRegistry.inputRendersOwnError(def.kind) ? { rendersOwnError: true } : {}),
|
|
195
197
|
component: (props) => input({ ...props, def }),
|
|
196
198
|
}
|
|
197
199
|
}
|