@open-mercato/ui 0.4.9-develop-d989387b7a → 0.4.9-develop-7120508245
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.
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { flash } from "../FlashMessages.js";
|
|
3
3
|
import { deserializeOperationMetadata } from "@open-mercato/shared/lib/commands/operationMetadata";
|
|
4
|
+
import { readJsonSafe } from "@open-mercato/shared/lib/http/readJsonSafe";
|
|
4
5
|
import { pushOperation } from "../operations/store.js";
|
|
5
6
|
import { pushPartialIndexWarning } from "../indexes/store.js";
|
|
6
7
|
import { createScopedHeaderStack } from "./scopedHeaderStack.js";
|
|
@@ -80,6 +81,7 @@ async function apiFetch(input, init) {
|
|
|
80
81
|
}
|
|
81
82
|
const scoped = scopedHeaders.resolveScopedHeaders();
|
|
82
83
|
const mergedInit = Object.keys(scoped).length ? { ...init ?? {}, headers: mergeHeaders(init?.headers, scoped) } : init;
|
|
84
|
+
const disableForbiddenRedirect = new Headers(mergedInit?.headers).get("x-om-forbidden-redirect") === "0";
|
|
83
85
|
const res = await baseFetch(input, mergedInit);
|
|
84
86
|
const pathname = typeof window !== "undefined" ? window.location.pathname : "";
|
|
85
87
|
const onLoginPage = pathname.startsWith("/login");
|
|
@@ -95,15 +97,17 @@ async function apiFetch(input, init) {
|
|
|
95
97
|
let roles = null;
|
|
96
98
|
let features = null;
|
|
97
99
|
let payload = null;
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
if (
|
|
104
|
-
|
|
100
|
+
const aclData = await readJsonSafe(res.clone(), null);
|
|
101
|
+
if (aclData && typeof aclData === "object") {
|
|
102
|
+
if (Array.isArray(aclData.requiredRoles)) {
|
|
103
|
+
roles = aclData.requiredRoles.map((r) => String(r));
|
|
104
|
+
}
|
|
105
|
+
if (Array.isArray(aclData.requiredFeatures)) {
|
|
106
|
+
features = aclData.requiredFeatures.map((f) => String(f));
|
|
107
|
+
}
|
|
108
|
+
payload = aclData;
|
|
105
109
|
}
|
|
106
|
-
if (!onLoginPage && !onPortalRoute) {
|
|
110
|
+
if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {
|
|
107
111
|
const target = typeof input === "string" ? input : input instanceof URL ? input.toString() : typeof Request !== "undefined" && input instanceof Request ? input.url : "unknown";
|
|
108
112
|
try {
|
|
109
113
|
console.warn("[apiFetch] Forbidden response", {
|
|
@@ -119,7 +123,16 @@ async function apiFetch(input, init) {
|
|
|
119
123
|
if (hasAclHints) {
|
|
120
124
|
redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features });
|
|
121
125
|
}
|
|
122
|
-
const
|
|
126
|
+
const raw = await res.clone().text().catch(() => "Forbidden");
|
|
127
|
+
let msg = raw;
|
|
128
|
+
const parsed = await readJsonSafe(raw, null);
|
|
129
|
+
if (parsed && typeof parsed === "object") {
|
|
130
|
+
if (typeof parsed.error === "string") {
|
|
131
|
+
msg = parsed.error;
|
|
132
|
+
} else if (typeof parsed.message === "string") {
|
|
133
|
+
msg = parsed.message;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
123
136
|
throw new ForbiddenError(msg);
|
|
124
137
|
}
|
|
125
138
|
}
|
|
@@ -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 { pushOperation } from '../operations/store'\nimport { pushPartialIndexWarning } from '../indexes/store'\nimport { createScopedHeaderStack } from './scopedHeaderStack'\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\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\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 constructor(message = 'Forbidden') {\n super(message)\n this.name = 'ForbiddenError'\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\nexport function redirectToForbiddenLogin(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n if (window.location.pathname.startsWith('/login')) 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 const current = window.location.pathname + window.location.search\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const fallbackRoles = DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n const effectiveRoles = roles.length ? roles : fallbackRoles\n const query = features.length\n ? `requireFeature=${encodeURIComponent(features.join(','))}`\n : effectiveRoles.length\n ? `requireRole=${encodeURIComponent(effectiveRoles.map(String).join(','))}`\n : ''\n const url = query\n ? `/login?${query}&redirect=${encodeURIComponent(current)}`\n : `/login?redirect=${encodeURIComponent(current)}`\n flash('Insufficient permissions. Redirecting to login\u2026', 'warning')\n setTimeout(() => { window.location.href = url }, 60)\n } catch {\n // no-op\n }\n}\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 mergedInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init\n const res = await baseFetch(input, mergedInit)\n const pathname = typeof window !== 'undefined' ? window.location.pathname : ''\n const onLoginPage = pathname.startsWith('/login')\n const onPortalRoute = /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\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) {\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
|
|
5
|
-
"mappings": "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
|
|
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'\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\nexport async function withScopedApiHeaders<T>(headers: Record<string, string>, run: () => Promise<T>): Promise<T> {\n return scopedHeaders.withScopedHeaders(headers, run)\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 constructor(message = 'Forbidden') {\n super(message)\n this.name = 'ForbiddenError'\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\nexport function redirectToForbiddenLogin(options?: { requiredRoles?: string[] | null; requiredFeatures?: string[] | null }) {\n if (typeof window === 'undefined') return\n if (window.location.pathname.startsWith('/login')) 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 const current = window.location.pathname + window.location.search\n const features = options?.requiredFeatures?.filter(Boolean) ?? []\n const roles = options?.requiredRoles?.filter(Boolean) ?? []\n const fallbackRoles = DEFAULT_FORBIDDEN_ROLES.filter(Boolean)\n const effectiveRoles = roles.length ? roles : fallbackRoles\n const query = features.length\n ? `requireFeature=${encodeURIComponent(features.join(','))}`\n : effectiveRoles.length\n ? `requireRole=${encodeURIComponent(effectiveRoles.map(String).join(','))}`\n : ''\n const url = query\n ? `/login?${query}&redirect=${encodeURIComponent(current)}`\n : `/login?redirect=${encodeURIComponent(current)}`\n flash('Insufficient permissions. Redirecting to login\u2026', 'warning')\n setTimeout(() => { window.location.href = url }, 60)\n } catch {\n // no-op\n }\n}\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 mergedInit = Object.keys(scoped).length\n ? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }\n : init\n const disableForbiddenRedirect = new Headers(mergedInit?.headers).get('x-om-forbidden-redirect') === '0'\n const res = await baseFetch(input, mergedInit)\n const pathname = typeof window !== 'undefined' ? window.location.pathname : ''\n const onLoginPage = pathname.startsWith('/login')\n const onPortalRoute = /\\/[^/]+\\/portal(\\/|$)/.test(pathname)\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) {\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 // eslint-disable-next-line no-console\n console.warn('[apiFetch] 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 redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })\n }\n const raw = await res.clone().text().catch(() => 'Forbidden')\n let msg = raw\n const parsed = await readJsonSafe<Record<string, unknown>>(raw, null)\n if (parsed && typeof parsed === 'object') {\n if (typeof parsed.error === 'string') {\n msg = parsed.error\n } else if (typeof parsed.message === 'string') {\n msg = parsed.message\n }\n }\n throw new ForbiddenError(msg)\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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.4.9-develop-
|
|
3
|
+
"version": "0.4.9-develop-7120508245",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -124,12 +124,12 @@
|
|
|
124
124
|
"recharts": "^2.15.0"
|
|
125
125
|
},
|
|
126
126
|
"peerDependencies": {
|
|
127
|
-
"@open-mercato/shared": "0.4.9-develop-
|
|
127
|
+
"@open-mercato/shared": "0.4.9-develop-7120508245",
|
|
128
128
|
"react": ">=18.0.0",
|
|
129
129
|
"react-dom": ">=18.0.0"
|
|
130
130
|
},
|
|
131
131
|
"devDependencies": {
|
|
132
|
-
"@open-mercato/shared": "0.4.9-develop-
|
|
132
|
+
"@open-mercato/shared": "0.4.9-develop-7120508245",
|
|
133
133
|
"@testing-library/dom": "^10.4.1",
|
|
134
134
|
"@testing-library/jest-dom": "^6.9.1",
|
|
135
135
|
"@testing-library/react": "^16.3.1",
|
package/src/backend/utils/api.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// Used across UI data utilities to avoid duplication.
|
|
4
4
|
import { flash } from '../FlashMessages'
|
|
5
5
|
import { deserializeOperationMetadata } from '@open-mercato/shared/lib/commands/operationMetadata'
|
|
6
|
+
import { readJsonSafe } from '@open-mercato/shared/lib/http/readJsonSafe'
|
|
6
7
|
import { pushOperation } from '../operations/store'
|
|
7
8
|
import { pushPartialIndexWarning } from '../indexes/store'
|
|
8
9
|
import { createScopedHeaderStack } from './scopedHeaderStack'
|
|
@@ -107,6 +108,7 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
107
108
|
const mergedInit = Object.keys(scoped).length
|
|
108
109
|
? { ...(init ?? {}), headers: mergeHeaders(init?.headers, scoped) }
|
|
109
110
|
: init
|
|
111
|
+
const disableForbiddenRedirect = new Headers(mergedInit?.headers).get('x-om-forbidden-redirect') === '0'
|
|
110
112
|
const res = await baseFetch(input, mergedInit)
|
|
111
113
|
const pathname = typeof window !== 'undefined' ? window.location.pathname : ''
|
|
112
114
|
const onLoginPage = pathname.startsWith('/login')
|
|
@@ -126,15 +128,18 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
126
128
|
let roles: string[] | null = null
|
|
127
129
|
let features: string[] | null = null
|
|
128
130
|
let payload: unknown = null
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
if (
|
|
135
|
-
|
|
131
|
+
const aclData = await readJsonSafe<Record<string, unknown>>(res.clone(), null)
|
|
132
|
+
if (aclData && typeof aclData === 'object') {
|
|
133
|
+
if (Array.isArray(aclData.requiredRoles)) {
|
|
134
|
+
roles = aclData.requiredRoles.map((r) => String(r))
|
|
135
|
+
}
|
|
136
|
+
if (Array.isArray(aclData.requiredFeatures)) {
|
|
137
|
+
features = aclData.requiredFeatures.map((f) => String(f))
|
|
138
|
+
}
|
|
139
|
+
payload = aclData
|
|
140
|
+
}
|
|
136
141
|
// Only redirect if not already on login page or a portal route
|
|
137
|
-
if (!onLoginPage && !onPortalRoute) {
|
|
142
|
+
if (!onLoginPage && !onPortalRoute && !disableForbiddenRedirect) {
|
|
138
143
|
const target =
|
|
139
144
|
typeof input === 'string'
|
|
140
145
|
? input
|
|
@@ -157,7 +162,16 @@ export async function apiFetch(input: RequestInfo | URL, init?: RequestInit): Pr
|
|
|
157
162
|
if (hasAclHints) {
|
|
158
163
|
redirectToForbiddenLogin({ requiredRoles: roles, requiredFeatures: features })
|
|
159
164
|
}
|
|
160
|
-
const
|
|
165
|
+
const raw = await res.clone().text().catch(() => 'Forbidden')
|
|
166
|
+
let msg = raw
|
|
167
|
+
const parsed = await readJsonSafe<Record<string, unknown>>(raw, null)
|
|
168
|
+
if (parsed && typeof parsed === 'object') {
|
|
169
|
+
if (typeof parsed.error === 'string') {
|
|
170
|
+
msg = parsed.error
|
|
171
|
+
} else if (typeof parsed.message === 'string') {
|
|
172
|
+
msg = parsed.message
|
|
173
|
+
}
|
|
174
|
+
}
|
|
161
175
|
throw new ForbiddenError(msg)
|
|
162
176
|
}
|
|
163
177
|
// If already on login, just return the response for the caller to handle
|