@open-mercato/ui 0.6.5-develop.4964.1.ae0edca575 → 0.6.5-develop.5048.1.fd82f4ae17
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.
|
@@ -8,6 +8,22 @@ const flashKindToAlertStatus = {
|
|
|
8
8
|
warning: "warning",
|
|
9
9
|
info: "information"
|
|
10
10
|
};
|
|
11
|
+
function normalizeFlashKind(value) {
|
|
12
|
+
if (value && Object.prototype.hasOwnProperty.call(flashKindToAlertStatus, value)) {
|
|
13
|
+
return value;
|
|
14
|
+
}
|
|
15
|
+
return "success";
|
|
16
|
+
}
|
|
17
|
+
function isSameOriginFlashNavigation() {
|
|
18
|
+
if (typeof window === "undefined") return false;
|
|
19
|
+
const referrer = document.referrer;
|
|
20
|
+
if (!referrer) return true;
|
|
21
|
+
try {
|
|
22
|
+
return new URL(referrer).origin === window.location.origin;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
11
27
|
function flash(message, type = "info") {
|
|
12
28
|
if (typeof window === "undefined") return;
|
|
13
29
|
const evt = new CustomEvent("flash", { detail: { message, type } });
|
|
@@ -101,12 +117,15 @@ function FlashMessagesInner() {
|
|
|
101
117
|
if (typeof window === "undefined") return;
|
|
102
118
|
const url = new URL(window.location.href);
|
|
103
119
|
const message = url.searchParams.get("flash");
|
|
104
|
-
const type = url.searchParams.get("type")
|
|
120
|
+
const type = normalizeFlashKind(url.searchParams.get("type"));
|
|
105
121
|
if (message) {
|
|
106
|
-
|
|
122
|
+
const trusted = isSameOriginFlashNavigation();
|
|
107
123
|
url.searchParams.delete("flash");
|
|
108
124
|
url.searchParams.delete("type");
|
|
109
125
|
window.history.replaceState({}, "", url.toString());
|
|
126
|
+
if (trusted) {
|
|
127
|
+
showFlash(message, type);
|
|
128
|
+
}
|
|
110
129
|
}
|
|
111
130
|
}, [locationKey, showFlash]);
|
|
112
131
|
React.useEffect(() => {
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"version": 3,
|
|
3
3
|
"sources": ["../../src/backend/FlashMessages.tsx"],
|
|
4
|
-
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Alert, type AlertStatus } from '../primitives/alert'\n\nexport type FlashKind = 'success' | 'error' | 'warning' | 'info'\n\nconst flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {\n success: 'success',\n error: 'error',\n warning: 'warning',\n info: 'information',\n}\n\n// Programmatic API to show a flash message without navigation.\n// Consumers can import { flash } and call flash('text', 'error').\nexport function flash(message: string, type: FlashKind = 'info') {\n if (typeof window === 'undefined') return\n const evt = new CustomEvent('flash', { detail: { message, type } })\n window.dispatchEvent(evt)\n}\n\ntype HistoryMethod = History['pushState']\n\nfunction useLocationKey() {\n const [locationKey, setLocationKey] = React.useState(() => {\n if (typeof window === 'undefined') return ''\n return window.location.href\n })\n const locationKeyRef = React.useRef(locationKey)\n\n React.useEffect(() => {\n locationKeyRef.current = locationKey\n }, [locationKey])\n\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n\n let active = true\n const scheduleUpdate = (href: string) => {\n const run = () => {\n if (!active) return\n if (locationKeyRef.current === href) return\n locationKeyRef.current = href\n setLocationKey(href)\n }\n if (typeof queueMicrotask === 'function') {\n queueMicrotask(run)\n } else {\n setTimeout(run, 0)\n }\n }\n const updateLocation = () => {\n if (!active) return\n const href = window.location.href\n if (href === locationKeyRef.current) return\n scheduleUpdate(href)\n }\n\n const deferredUpdateLocation = () => {\n setTimeout(updateLocation, 0)\n }\n\n const originalPush: HistoryMethod = window.history.pushState.bind(window.history)\n const originalReplace: HistoryMethod = window.history.replaceState.bind(window.history)\n\n const pushState: HistoryMethod = (...args) => {\n originalPush(...args)\n deferredUpdateLocation()\n }\n\n const replaceState: HistoryMethod = (...args) => {\n originalReplace(...args)\n deferredUpdateLocation()\n }\n\n window.history.pushState = pushState\n window.history.replaceState = replaceState\n window.addEventListener('popstate', updateLocation)\n window.addEventListener('hashchange', updateLocation)\n updateLocation()\n\n return () => {\n active = false\n window.history.pushState = originalPush\n window.history.replaceState = originalReplace\n window.removeEventListener('popstate', updateLocation)\n window.removeEventListener('hashchange', updateLocation)\n }\n }, [])\n\n return locationKey\n}\n\nfunction FlashMessagesInner() {\n const [msg, setMsg] = React.useState<string | null>(null)\n const [kind, setKind] = React.useState<FlashKind>('info')\n const locationKey = useLocationKey()\n const dismissTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n const clearDismissTimer = React.useCallback(() => {\n if (dismissTimerRef.current) {\n clearTimeout(dismissTimerRef.current)\n dismissTimerRef.current = null\n }\n }, [])\n\n const showFlash = React.useCallback((message: string, type: FlashKind) => {\n clearDismissTimer()\n setMsg(message)\n setKind(type)\n dismissTimerRef.current = setTimeout(() => {\n dismissTimerRef.current = null\n setMsg(null)\n }, 3000)\n }, [clearDismissTimer])\n\n React.useEffect(() => {\n return () => {\n clearDismissTimer()\n }\n }, [clearDismissTimer])\n\n // Read flash from URL on any navigation change (client-side too)\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const url = new URL(window.location.href)\n const message = url.searchParams.get('flash')\n const type = (url.searchParams.get('type')
|
|
5
|
-
"mappings": ";
|
|
4
|
+
"sourcesContent": ["\"use client\"\nimport * as React from 'react'\nimport { Alert, type AlertStatus } from '../primitives/alert'\n\nexport type FlashKind = 'success' | 'error' | 'warning' | 'info'\n\nconst flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {\n success: 'success',\n error: 'error',\n warning: 'warning',\n info: 'information',\n}\n\nfunction normalizeFlashKind(value: string | null | undefined): FlashKind {\n if (value && Object.prototype.hasOwnProperty.call(flashKindToAlertStatus, value)) {\n return value as FlashKind\n }\n return 'success'\n}\n\n// A URL-supplied flash is only trustworthy when the navigation that carried it\n// originated from the same origin (an in-app POST->GET redirect or client-side\n// navigation). Cross-origin links \u2014 the phishing/content-spoofing vector \u2014 must\n// not be allowed to render attacker-controlled copy inside an authoritative banner.\nfunction isSameOriginFlashNavigation(): boolean {\n if (typeof window === 'undefined') return false\n const referrer = document.referrer\n // No referrer (direct load, bookmark, or referrer stripped) is ambiguous, not a\n // cross-origin redirect we can attribute to an attacker page; allow it.\n if (!referrer) return true\n try {\n return new URL(referrer).origin === window.location.origin\n } catch {\n return false\n }\n}\n\n// Programmatic API to show a flash message without navigation.\n// Consumers can import { flash } and call flash('text', 'error').\nexport function flash(message: string, type: FlashKind = 'info') {\n if (typeof window === 'undefined') return\n const evt = new CustomEvent('flash', { detail: { message, type } })\n window.dispatchEvent(evt)\n}\n\ntype HistoryMethod = History['pushState']\n\nfunction useLocationKey() {\n const [locationKey, setLocationKey] = React.useState(() => {\n if (typeof window === 'undefined') return ''\n return window.location.href\n })\n const locationKeyRef = React.useRef(locationKey)\n\n React.useEffect(() => {\n locationKeyRef.current = locationKey\n }, [locationKey])\n\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n\n let active = true\n const scheduleUpdate = (href: string) => {\n const run = () => {\n if (!active) return\n if (locationKeyRef.current === href) return\n locationKeyRef.current = href\n setLocationKey(href)\n }\n if (typeof queueMicrotask === 'function') {\n queueMicrotask(run)\n } else {\n setTimeout(run, 0)\n }\n }\n const updateLocation = () => {\n if (!active) return\n const href = window.location.href\n if (href === locationKeyRef.current) return\n scheduleUpdate(href)\n }\n\n const deferredUpdateLocation = () => {\n setTimeout(updateLocation, 0)\n }\n\n const originalPush: HistoryMethod = window.history.pushState.bind(window.history)\n const originalReplace: HistoryMethod = window.history.replaceState.bind(window.history)\n\n const pushState: HistoryMethod = (...args) => {\n originalPush(...args)\n deferredUpdateLocation()\n }\n\n const replaceState: HistoryMethod = (...args) => {\n originalReplace(...args)\n deferredUpdateLocation()\n }\n\n window.history.pushState = pushState\n window.history.replaceState = replaceState\n window.addEventListener('popstate', updateLocation)\n window.addEventListener('hashchange', updateLocation)\n updateLocation()\n\n return () => {\n active = false\n window.history.pushState = originalPush\n window.history.replaceState = originalReplace\n window.removeEventListener('popstate', updateLocation)\n window.removeEventListener('hashchange', updateLocation)\n }\n }, [])\n\n return locationKey\n}\n\nfunction FlashMessagesInner() {\n const [msg, setMsg] = React.useState<string | null>(null)\n const [kind, setKind] = React.useState<FlashKind>('info')\n const locationKey = useLocationKey()\n const dismissTimerRef = React.useRef<ReturnType<typeof setTimeout> | null>(null)\n\n const clearDismissTimer = React.useCallback(() => {\n if (dismissTimerRef.current) {\n clearTimeout(dismissTimerRef.current)\n dismissTimerRef.current = null\n }\n }, [])\n\n const showFlash = React.useCallback((message: string, type: FlashKind) => {\n clearDismissTimer()\n setMsg(message)\n setKind(type)\n dismissTimerRef.current = setTimeout(() => {\n dismissTimerRef.current = null\n setMsg(null)\n }, 3000)\n }, [clearDismissTimer])\n\n React.useEffect(() => {\n return () => {\n clearDismissTimer()\n }\n }, [clearDismissTimer])\n\n // Read flash from URL on any navigation change (client-side too)\n React.useEffect(() => {\n if (typeof window === 'undefined') return\n const url = new URL(window.location.href)\n const message = url.searchParams.get('flash')\n const type = normalizeFlashKind(url.searchParams.get('type'))\n if (message) {\n // Always strip the params so a spoofed link does not linger in history,\n // but only render the banner for same-origin navigations.\n const trusted = isSameOriginFlashNavigation()\n url.searchParams.delete('flash')\n url.searchParams.delete('type')\n window.history.replaceState({}, '', url.toString())\n if (trusted) {\n showFlash(message, type)\n }\n }\n }, [locationKey, showFlash])\n\n // Listen for programmatic flash events\n React.useEffect(() => {\n const handler = (e: Event) => {\n const ce = e as CustomEvent<{ message?: string; type?: FlashKind }>\n const text = ce.detail?.message\n const t = ce.detail?.type || 'info'\n if (!text) return\n showFlash(text, t)\n }\n window.addEventListener('flash', handler as EventListener)\n return () => window.removeEventListener('flash', handler as EventListener)\n }, [showFlash])\n\n const handleDismiss = React.useCallback(() => {\n clearDismissTimer()\n setMsg(null)\n }, [clearDismissTimer])\n\n if (!msg) return null\n\n return (\n <div className=\"pointer-events-none fixed left-3 right-3 top-3 z-toast sm:left-auto sm:right-4 sm:w-[380px]\">\n <div className=\"pointer-events-auto\">\n <Alert\n status={flashKindToAlertStatus[kind]}\n size=\"sm\"\n dismissible\n onDismiss={handleDismiss}\n className=\"shadow-md\"\n >\n {msg}\n </Alert>\n </div>\n </div>\n )\n}\n\nexport function FlashMessages() {\n return (\n <React.Suspense fallback={null}>\n <FlashMessagesInner />\n </React.Suspense>\n )\n}\n"],
|
|
5
|
+
"mappings": "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",
|
|
6
6
|
"names": []
|
|
7
7
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@open-mercato/ui",
|
|
3
|
-
"version": "0.6.5-develop.
|
|
3
|
+
"version": "0.6.5-develop.5048.1.fd82f4ae17",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"main": "./dist/index.js",
|
|
6
6
|
"scripts": {
|
|
@@ -149,13 +149,13 @@
|
|
|
149
149
|
"remark-gfm": "^4.0.1"
|
|
150
150
|
},
|
|
151
151
|
"peerDependencies": {
|
|
152
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
152
|
+
"@open-mercato/shared": "0.6.5-develop.5048.1.fd82f4ae17",
|
|
153
153
|
"react": ">=18.0.0",
|
|
154
154
|
"react-dom": ">=18.0.0",
|
|
155
155
|
"react-is": ">=18.0.0"
|
|
156
156
|
},
|
|
157
157
|
"devDependencies": {
|
|
158
|
-
"@open-mercato/shared": "0.6.5-develop.
|
|
158
|
+
"@open-mercato/shared": "0.6.5-develop.5048.1.fd82f4ae17",
|
|
159
159
|
"@testing-library/dom": "^10.4.1",
|
|
160
160
|
"@testing-library/jest-dom": "^6.9.1",
|
|
161
161
|
"@testing-library/react": "^16.3.1",
|
|
@@ -11,6 +11,30 @@ const flashKindToAlertStatus: Record<FlashKind, AlertStatus> = {
|
|
|
11
11
|
info: 'information',
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
+
function normalizeFlashKind(value: string | null | undefined): FlashKind {
|
|
15
|
+
if (value && Object.prototype.hasOwnProperty.call(flashKindToAlertStatus, value)) {
|
|
16
|
+
return value as FlashKind
|
|
17
|
+
}
|
|
18
|
+
return 'success'
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// A URL-supplied flash is only trustworthy when the navigation that carried it
|
|
22
|
+
// originated from the same origin (an in-app POST->GET redirect or client-side
|
|
23
|
+
// navigation). Cross-origin links — the phishing/content-spoofing vector — must
|
|
24
|
+
// not be allowed to render attacker-controlled copy inside an authoritative banner.
|
|
25
|
+
function isSameOriginFlashNavigation(): boolean {
|
|
26
|
+
if (typeof window === 'undefined') return false
|
|
27
|
+
const referrer = document.referrer
|
|
28
|
+
// No referrer (direct load, bookmark, or referrer stripped) is ambiguous, not a
|
|
29
|
+
// cross-origin redirect we can attribute to an attacker page; allow it.
|
|
30
|
+
if (!referrer) return true
|
|
31
|
+
try {
|
|
32
|
+
return new URL(referrer).origin === window.location.origin
|
|
33
|
+
} catch {
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
14
38
|
// Programmatic API to show a flash message without navigation.
|
|
15
39
|
// Consumers can import { flash } and call flash('text', 'error').
|
|
16
40
|
export function flash(message: string, type: FlashKind = 'info') {
|
|
@@ -125,12 +149,17 @@ function FlashMessagesInner() {
|
|
|
125
149
|
if (typeof window === 'undefined') return
|
|
126
150
|
const url = new URL(window.location.href)
|
|
127
151
|
const message = url.searchParams.get('flash')
|
|
128
|
-
const type = (url.searchParams.get('type')
|
|
152
|
+
const type = normalizeFlashKind(url.searchParams.get('type'))
|
|
129
153
|
if (message) {
|
|
130
|
-
|
|
154
|
+
// Always strip the params so a spoofed link does not linger in history,
|
|
155
|
+
// but only render the banner for same-origin navigations.
|
|
156
|
+
const trusted = isSameOriginFlashNavigation()
|
|
131
157
|
url.searchParams.delete('flash')
|
|
132
158
|
url.searchParams.delete('type')
|
|
133
159
|
window.history.replaceState({}, '', url.toString())
|
|
160
|
+
if (trusted) {
|
|
161
|
+
showFlash(message, type)
|
|
162
|
+
}
|
|
134
163
|
}
|
|
135
164
|
}, [locationKey, showFlash])
|
|
136
165
|
|
|
@@ -3,16 +3,25 @@ import { act, screen } from '@testing-library/react'
|
|
|
3
3
|
import { renderWithProviders } from '@open-mercato/shared/lib/testing/renderWithProviders'
|
|
4
4
|
import { FlashMessages, flash } from '../FlashMessages'
|
|
5
5
|
|
|
6
|
+
function setReferrer(value: string) {
|
|
7
|
+
Object.defineProperty(document, 'referrer', {
|
|
8
|
+
configurable: true,
|
|
9
|
+
value,
|
|
10
|
+
})
|
|
11
|
+
}
|
|
12
|
+
|
|
6
13
|
describe('FlashMessages', () => {
|
|
7
14
|
beforeEach(() => {
|
|
8
15
|
jest.useFakeTimers()
|
|
9
16
|
window.history.replaceState({}, '', 'http://localhost/backend')
|
|
17
|
+
setReferrer('')
|
|
10
18
|
})
|
|
11
19
|
|
|
12
20
|
afterEach(() => {
|
|
13
21
|
jest.runOnlyPendingTimers()
|
|
14
22
|
jest.useRealTimers()
|
|
15
23
|
window.history.replaceState({}, '', 'http://localhost/backend')
|
|
24
|
+
setReferrer('')
|
|
16
25
|
})
|
|
17
26
|
|
|
18
27
|
it('auto-dismisses URL-based flashes after stripping query params', () => {
|
|
@@ -30,6 +39,44 @@ describe('FlashMessages', () => {
|
|
|
30
39
|
expect(screen.queryByText('Saved')).not.toBeInTheDocument()
|
|
31
40
|
})
|
|
32
41
|
|
|
42
|
+
it('honors same-origin referrer flashes', () => {
|
|
43
|
+
setReferrer('http://localhost/backend/checkout/templates')
|
|
44
|
+
window.history.replaceState({}, '', 'http://localhost/backend/checkout/pay-links?flash=Saved&type=success')
|
|
45
|
+
|
|
46
|
+
renderWithProviders(<FlashMessages />)
|
|
47
|
+
|
|
48
|
+
expect(screen.getByText('Saved')).toBeInTheDocument()
|
|
49
|
+
expect(window.location.search).toBe('')
|
|
50
|
+
})
|
|
51
|
+
|
|
52
|
+
it('suppresses cross-origin referrer flashes but still strips the params', () => {
|
|
53
|
+
setReferrer('https://attacker.example.com/phish')
|
|
54
|
+
window.history.replaceState(
|
|
55
|
+
{},
|
|
56
|
+
'',
|
|
57
|
+
'http://localhost/backend/dashboard?flash=Your+account+was+suspended&type=error',
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
renderWithProviders(<FlashMessages />)
|
|
61
|
+
|
|
62
|
+
expect(screen.queryByText('Your account was suspended')).not.toBeInTheDocument()
|
|
63
|
+
expect(window.location.search).toBe('')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('falls back to a safe kind when the type param is not an allowed FlashKind', () => {
|
|
67
|
+
window.history.replaceState(
|
|
68
|
+
{},
|
|
69
|
+
'',
|
|
70
|
+
'http://localhost/backend/dashboard?flash=Saved&type=javascript',
|
|
71
|
+
)
|
|
72
|
+
|
|
73
|
+
renderWithProviders(<FlashMessages />)
|
|
74
|
+
|
|
75
|
+
expect(screen.getByText('Saved')).toBeInTheDocument()
|
|
76
|
+
const badge = document.querySelector('[data-slot="alert-icon-badge"]')
|
|
77
|
+
expect(badge?.getAttribute('data-status')).toBe('success')
|
|
78
|
+
})
|
|
79
|
+
|
|
33
80
|
it('auto-dismisses programmatic flashes', () => {
|
|
34
81
|
renderWithProviders(<FlashMessages />)
|
|
35
82
|
|