@flamingo-stack/openframe-frontend-core 0.0.216 → 0.0.217-snapshot.20260601151546
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/{chunk-2V4SACHE.js → chunk-EKBM4FHK.js} +2 -2
- package/dist/{chunk-VC3ND5RB.js → chunk-EWA2NFUR.js} +2 -2
- package/dist/{chunk-IH76P5R6.cjs → chunk-FZZBCRID.cjs} +8 -86
- package/dist/chunk-FZZBCRID.cjs.map +1 -0
- package/dist/{chunk-K4DFAVSO.cjs → chunk-GE64T3JT.cjs} +9 -9
- package/dist/{chunk-K4DFAVSO.cjs.map → chunk-GE64T3JT.cjs.map} +1 -1
- package/dist/{chunk-ZGTDUPTW.cjs → chunk-L5RSJE2I.cjs} +2318 -1136
- package/dist/chunk-L5RSJE2I.cjs.map +1 -0
- package/dist/{chunk-QTKU6ULP.js → chunk-OHOUSDAY.js} +2 -2
- package/dist/{chunk-572WQWIX.cjs → chunk-S4SVD5JI.cjs} +9 -9
- package/dist/{chunk-572WQWIX.cjs.map → chunk-S4SVD5JI.cjs.map} +1 -1
- package/dist/{chunk-GVNQAGXB.js → chunk-SWIR5EB2.js} +3 -81
- package/dist/chunk-SWIR5EB2.js.map +1 -0
- package/dist/{chunk-SMCG2CCC.cjs → chunk-TCJ5B2ZD.cjs} +24 -24
- package/dist/{chunk-SMCG2CCC.cjs.map → chunk-TCJ5B2ZD.cjs.map} +1 -1
- package/dist/{chunk-CDLYRFDE.js → chunk-V5JY5RSY.js} +5710 -4528
- package/dist/chunk-V5JY5RSY.js.map +1 -0
- package/dist/components/chat/chat-attachment-bar.d.ts +13 -2
- package/dist/components/chat/chat-attachment-bar.d.ts.map +1 -1
- package/dist/components/chat/chat-input.d.ts.map +1 -1
- package/dist/components/chat/chat-message-row.d.ts +25 -0
- package/dist/components/chat/chat-message-row.d.ts.map +1 -0
- package/dist/components/chat/embeddable-chat.d.ts +13 -0
- package/dist/components/chat/embeddable-chat.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts +104 -10
- package/dist/components/chat/hooks/use-nats-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-slash-commands.d.ts +6 -0
- package/dist/components/chat/hooks/use-slash-commands.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-sse-chat-adapter.d.ts.map +1 -1
- package/dist/components/chat/hooks/use-unified-chat.d.ts.map +1 -1
- package/dist/components/chat/index.cjs +6 -2
- package/dist/components/chat/index.cjs.map +1 -1
- package/dist/components/chat/index.d.ts +1 -0
- package/dist/components/chat/index.d.ts.map +1 -1
- package/dist/components/chat/index.js +5 -1
- package/dist/components/chat/types/component.types.d.ts +8 -1
- package/dist/components/chat/types/component.types.d.ts.map +1 -1
- package/dist/components/chat/types/unified-chat-state.types.d.ts +81 -0
- package/dist/components/chat/types/unified-chat-state.types.d.ts.map +1 -1
- package/dist/components/contact/index.cjs +3 -3
- package/dist/components/contact/index.js +2 -2
- package/dist/components/features/index.cjs +2 -2
- package/dist/components/features/index.js +1 -1
- package/dist/components/form.d.ts +1 -1
- package/dist/components/form.d.ts.map +1 -1
- package/dist/components/index.cjs +78 -52
- package/dist/components/index.cjs.map +1 -1
- package/dist/components/index.js +31 -5
- package/dist/components/index.js.map +1 -1
- package/dist/components/navigation/app-header.d.ts +7 -0
- package/dist/components/navigation/app-header.d.ts.map +1 -1
- package/dist/components/navigation/app-layout-drawer.d.ts +65 -0
- package/dist/components/navigation/app-layout-drawer.d.ts.map +1 -0
- package/dist/components/navigation/app-layout.d.ts +9 -1
- package/dist/components/navigation/app-layout.d.ts.map +1 -1
- package/dist/components/navigation/header-mingo-button.d.ts +21 -0
- package/dist/components/navigation/header-mingo-button.d.ts.map +1 -0
- package/dist/components/navigation/index.cjs +24 -2
- package/dist/components/navigation/index.cjs.map +1 -1
- package/dist/components/navigation/index.d.ts +5 -1
- package/dist/components/navigation/index.d.ts.map +1 -1
- package/dist/components/navigation/index.js +23 -1
- package/dist/components/onboarding-guides/index.cjs +18 -18
- package/dist/components/onboarding-guides/index.js +3 -3
- package/dist/components/shared/dev-section/dev-card-row.d.ts +5 -45
- package/dist/components/shared/dev-section/dev-card-row.d.ts.map +1 -1
- package/dist/components/shared/legal-document/use-legal-docs.d.ts.map +1 -1
- package/dist/components/tickets/help-center-card.d.ts.map +1 -1
- package/dist/components/tickets/help-center-list.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts +9 -1
- package/dist/components/tickets/hooks/use-ticket-engagements.d.ts.map +1 -1
- package/dist/components/tickets/hooks/use-tickets-list.d.ts +7 -0
- package/dist/components/tickets/hooks/use-tickets-list.d.ts.map +1 -1
- package/dist/components/tickets/index.cjs +309 -256
- package/dist/components/tickets/index.cjs.map +1 -1
- package/dist/components/tickets/index.d.ts +1 -0
- package/dist/components/tickets/index.d.ts.map +1 -1
- package/dist/components/tickets/index.js +376 -323
- package/dist/components/tickets/index.js.map +1 -1
- package/dist/components/tickets/ticket-detail-drawer.d.ts.map +1 -1
- package/dist/components/tickets/ticket-reply-composer.d.ts +33 -0
- package/dist/components/tickets/ticket-reply-composer.d.ts.map +1 -0
- package/dist/components/tickets/types.d.ts +13 -0
- package/dist/components/tickets/types.d.ts.map +1 -1
- package/dist/components/ui/index.cjs +6 -2
- package/dist/components/ui/index.cjs.map +1 -1
- package/dist/components/ui/index.js +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts +5 -1
- package/dist/components/ui/ticket-attachments-list.d.ts.map +1 -1
- package/dist/index.cjs +30 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.js +29 -1
- package/dist/utils/embed-authed-fetch.d.ts +80 -0
- package/dist/utils/embed-authed-fetch.d.ts.map +1 -1
- package/dist/utils/index.cjs +129 -9
- package/dist/utils/index.cjs.map +1 -1
- package/dist/utils/index.d.ts +1 -1
- package/dist/utils/index.d.ts.map +1 -1
- package/dist/utils/index.js +129 -10
- package/dist/utils/index.js.map +1 -1
- package/dist/utils/scroll-into-view.d.ts +43 -48
- package/dist/utils/scroll-into-view.d.ts.map +1 -1
- package/package.json +2 -2
- package/src/components/chat/chat-attachment-bar.tsx +58 -22
- package/src/components/chat/chat-input.tsx +68 -29
- package/src/components/chat/chat-message-row.tsx +124 -0
- package/src/components/chat/embeddable-chat.tsx +154 -37
- package/src/components/chat/hooks/use-nats-chat-adapter.ts +601 -23
- package/src/components/chat/hooks/use-slash-commands.ts +10 -1
- package/src/components/chat/hooks/use-sse-chat-adapter.ts +45 -0
- package/src/components/chat/hooks/use-unified-chat.ts +59 -0
- package/src/components/chat/index.ts +1 -0
- package/src/components/chat/types/component.types.ts +8 -1
- package/src/components/chat/types/unified-chat-state.types.ts +116 -0
- package/src/components/navigation/app-header.tsx +23 -0
- package/src/components/navigation/app-layout-drawer.tsx +620 -0
- package/src/components/navigation/app-layout.tsx +65 -26
- package/src/components/navigation/header-mingo-button.tsx +58 -0
- package/src/components/navigation/index.ts +17 -1
- package/src/components/shared/dev-section/dev-card-row.tsx +5 -183
- package/src/components/shared/legal-document/use-legal-docs.ts +5 -1
- package/src/components/tickets/help-center-card.tsx +26 -29
- package/src/components/tickets/help-center-list.tsx +57 -10
- package/src/components/tickets/hooks/use-ticket-engagements.ts +41 -5
- package/src/components/tickets/hooks/use-tickets-list.ts +13 -0
- package/src/components/tickets/index.ts +4 -0
- package/src/components/tickets/ticket-detail-drawer.tsx +144 -200
- package/src/components/tickets/ticket-reply-composer.tsx +195 -0
- package/src/components/tickets/types.ts +14 -0
- package/src/components/ui/ticket-attachments-list.tsx +26 -8
- package/src/stories/AppLayoutDrawer.stories.tsx +228 -0
- package/src/styles/app-globals.css +13 -0
- package/src/utils/.embed-authed-fetch.md +7 -0
- package/src/utils/__tests__/embed-authed-fetch.test.ts +103 -1
- package/src/utils/embed-authed-fetch.ts +247 -7
- package/src/utils/index.ts +5 -1
- package/src/utils/scroll-into-view.ts +127 -53
- package/dist/chunk-CDLYRFDE.js.map +0 -1
- package/dist/chunk-GVNQAGXB.js.map +0 -1
- package/dist/chunk-IH76P5R6.cjs.map +0 -1
- package/dist/chunk-ZGTDUPTW.cjs.map +0 -1
- /package/dist/{chunk-2V4SACHE.js.map → chunk-EKBM4FHK.js.map} +0 -0
- /package/dist/{chunk-VC3ND5RB.js.map → chunk-EWA2NFUR.js.map} +0 -0
- /package/dist/{chunk-QTKU6ULP.js.map → chunk-OHOUSDAY.js.map} +0 -0
|
@@ -22,6 +22,117 @@
|
|
|
22
22
|
|
|
23
23
|
import { applyProxyAuth } from './embed-proxy-auth-storage'
|
|
24
24
|
|
|
25
|
+
// =============================================================================
|
|
26
|
+
// Host-supplied auth adapter (opt-in)
|
|
27
|
+
// =============================================================================
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Hosts that have their own auth model (cookie sessions, app-specific
|
|
31
|
+
* JWT in localStorage, OAuth access tokens, …) can register an adapter
|
|
32
|
+
* to override the lib's default `embedProxyAuth` flow. When set, the
|
|
33
|
+
* adapter's `getHeaders()` result is merged onto every `embedAuthedFetch`
|
|
34
|
+
* call AFTER the default proxy-auth header step (so adapter headers
|
|
35
|
+
* win over both caller and proxy values), and `credentials` overrides
|
|
36
|
+
* the default `'same-origin'` behaviour.
|
|
37
|
+
*
|
|
38
|
+
* Default (no adapter): MPH-style proxy-impersonation — bearer + act-as
|
|
39
|
+
* read from localStorage, `credentials: 'same-origin'`. No consumer
|
|
40
|
+
* needs to touch this unless they want a different auth model.
|
|
41
|
+
*
|
|
42
|
+
* Use cases:
|
|
43
|
+
* - openframe-frontend has its own JWT in `localStorage.of_access_token`
|
|
44
|
+
* and cookie-based session; register an adapter to attach the JWT
|
|
45
|
+
* and request `credentials: 'include'` so cookies travel cross-origin
|
|
46
|
+
* to the openframe gateway.
|
|
47
|
+
* - Future embed hosts with OAuth access tokens, signed URLs, etc.
|
|
48
|
+
*
|
|
49
|
+
* Lifetime: setter is module-level (intentionally — `embedAuthedFetch`
|
|
50
|
+
* is a plain utility, not a hook, so it can't read React context). Host
|
|
51
|
+
* runtime providers should call `setEmbedAuthAdapter(...)` on mount and
|
|
52
|
+
* `setEmbedAuthAdapter(null)` on unmount. Multiple hosts registering at
|
|
53
|
+
* once is a programming error (one chat panel per app).
|
|
54
|
+
*/
|
|
55
|
+
export interface EmbedAuthAdapter {
|
|
56
|
+
/** Headers merged onto every embedded-fetch call. Return `{}` to add
|
|
57
|
+
* nothing. Called per-request so reactive token refresh sees the latest
|
|
58
|
+
* value from your auth store / storage. Values typed as
|
|
59
|
+
* `string | undefined` so the common narrowed shape
|
|
60
|
+
* `{ Authorization: token ? 'Bearer …' : undefined }` (or a conditional
|
|
61
|
+
* `token ? { Authorization: … } : {}`) assigns cleanly — `undefined`
|
|
62
|
+
* values are filtered before being merged into the request headers. */
|
|
63
|
+
getHeaders?: () => Record<string, string | undefined>
|
|
64
|
+
/** `RequestInit.credentials` mode. Default when no adapter: callers'
|
|
65
|
+
* `init.credentials` or `'same-origin'`. Use `'include'` for cookie
|
|
66
|
+
* auth against a different origin (CORS + `SameSite=None` required). */
|
|
67
|
+
credentials?: RequestCredentials
|
|
68
|
+
/**
|
|
69
|
+
* Optional 401 self-heal. When a request comes back `401`,
|
|
70
|
+
* `embedAuthedFetch` calls this once, and — if it resolves `true` —
|
|
71
|
+
* retries the SAME request exactly once with freshly-recomputed
|
|
72
|
+
* headers (so a rotated bearer from `getHeaders()` is picked up).
|
|
73
|
+
* Resolve `false` to surface the 401 to the caller unchanged.
|
|
74
|
+
*
|
|
75
|
+
* This is the capability the openframe `apiClient` has had all along
|
|
76
|
+
* (refresh-the-access-token-then-retry); registering it here gives the
|
|
77
|
+
* embedded chat/ticket surfaces the same self-healing auth instead of
|
|
78
|
+
* dying on an expired token. Concurrent 401s are de-duplicated by the
|
|
79
|
+
* wrapper, so this fires at most once per refresh cycle even when a
|
|
80
|
+
* stampede of chat requests all expire together — your implementation
|
|
81
|
+
* does NOT need its own in-flight guard (though a token-refresh manager
|
|
82
|
+
* that already dedups is harmless).
|
|
83
|
+
*
|
|
84
|
+
* Keep it idempotent and side-effect-light: on failure the wrapper just
|
|
85
|
+
* returns the original 401 — logout/redirect decisions belong to the
|
|
86
|
+
* host's own auth layer, not to this fetch wrapper.
|
|
87
|
+
*/
|
|
88
|
+
refresh?: () => Promise<boolean>
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* The registered adapter is parked on `globalThis`, NOT in a module-private
|
|
93
|
+
* `let`. Reason: this lib ships multiple entry points (`/utils`,
|
|
94
|
+
* `/components/chat`, …) and a consumer's bundler can inline this file into
|
|
95
|
+
* more than one chunk — giving each chunk its OWN module scope. If the host
|
|
96
|
+
* calls `setEmbedAuthAdapter` from the `/utils` copy while the chat's
|
|
97
|
+
* `embedAuthedFetch` runs from the `/components/chat` copy, a module-local
|
|
98
|
+
* `let` would be set on one copy and read as `null` on the other (the exact
|
|
99
|
+
* "credentials: same-origin, no Bearer, no refresh" symptom). A single
|
|
100
|
+
* `globalThis` slot is shared across every copy, so registration always
|
|
101
|
+
* reaches the fetch path.
|
|
102
|
+
*/
|
|
103
|
+
const ADAPTER_GLOBAL_KEY = '__embedAuthedFetchAdapter__'
|
|
104
|
+
|
|
105
|
+
function getRegisteredAuthAdapter(): EmbedAuthAdapter | null {
|
|
106
|
+
if (typeof globalThis === 'undefined') return null
|
|
107
|
+
return (globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] as EmbedAuthAdapter | null ?? null
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function storeRegisteredAuthAdapter(adapter: EmbedAuthAdapter | null): void {
|
|
111
|
+
if (typeof globalThis === 'undefined') return
|
|
112
|
+
;(globalThis as Record<string, unknown>)[ADAPTER_GLOBAL_KEY] = adapter
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Register a host-owned auth adapter for `embedAuthedFetch`. Pass `null`
|
|
117
|
+
* to clear (typically on provider unmount).
|
|
118
|
+
*
|
|
119
|
+
* Module-level state — there is one chat panel per app, so a single
|
|
120
|
+
* registration is sufficient. Calling this twice with different non-null
|
|
121
|
+
* adapters replaces the previous one (the most recent registration wins);
|
|
122
|
+
* a `console.warn` flags the overwrite so duplicate-provider mounts get
|
|
123
|
+
* caught in dev.
|
|
124
|
+
*/
|
|
125
|
+
export function setEmbedAuthAdapter(adapter: EmbedAuthAdapter | null): void {
|
|
126
|
+
if (adapter && getRegisteredAuthAdapter() && process.env.NODE_ENV !== 'production') {
|
|
127
|
+
console.warn(
|
|
128
|
+
'[setEmbedAuthAdapter] overwriting a previously-registered auth ' +
|
|
129
|
+
'adapter. Two chat-runtime providers should not coexist — verify ' +
|
|
130
|
+
'mount order and pass `null` from the unmounting provider.',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
storeRegisteredAuthAdapter(adapter)
|
|
134
|
+
}
|
|
135
|
+
|
|
25
136
|
/**
|
|
26
137
|
* `fetch` wrapper that attaches embed-proxy bearer headers (when
|
|
27
138
|
* present in sessionStorage) and forces `credentials: 'same-origin'`
|
|
@@ -40,13 +151,26 @@ import { applyProxyAuth } from './embed-proxy-auth-storage'
|
|
|
40
151
|
* the current window's origin; cross-origin URLs throw before the bearer
|
|
41
152
|
* leaves the page. This is a defense-in-depth guard for future call sites
|
|
42
153
|
* — there is no legitimate cross-origin use of this fetch wrapper.
|
|
154
|
+
*
|
|
155
|
+
* **401 self-heal:** when a registered adapter supplies `refresh`, a `401`
|
|
156
|
+
* response triggers a single token refresh + retry of the same request
|
|
157
|
+
* (see `EmbedAuthAdapter.refresh`). This is the openframe `apiClient`'s
|
|
158
|
+
* refresh-then-retry behaviour, lifted into the lib so embedded surfaces
|
|
159
|
+
* no longer need a host-side `window.fetch` monkey-patch to survive an
|
|
160
|
+
* expired access token mid-chat. With no adapter (or no `refresh`), the
|
|
161
|
+
* 401 passes straight through unchanged.
|
|
43
162
|
*/
|
|
44
163
|
export function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<Response> {
|
|
164
|
+
// Same-origin guard runs SYNCHRONOUSLY (not awaited inside the async
|
|
165
|
+
// helper below) so a bearer-leaking cross-origin URL throws before any
|
|
166
|
+
// promise is created — callers and tests rely on the synchronous throw.
|
|
45
167
|
assertSameOrigin(url)
|
|
46
168
|
|
|
47
169
|
// `applyProxyAuth` accepts `Record<string, string>`; normalize the
|
|
48
|
-
// caller's headers to that shape. RequestInit accepts
|
|
49
|
-
// which is broader (Headers instance OR array of tuples).
|
|
170
|
+
// caller's headers to that shape ONCE, up front. RequestInit accepts
|
|
171
|
+
// `HeadersInit` which is broader (Headers instance OR array of tuples).
|
|
172
|
+
// We re-derive the per-request headers from this base on every attempt
|
|
173
|
+
// (initial + post-refresh retry) so a rotated bearer is picked up.
|
|
50
174
|
//
|
|
51
175
|
// When the caller passes no headers, fall back to the same default
|
|
52
176
|
// `applyProxyAuth` uses internally — `Content-Type: application/json` —
|
|
@@ -69,14 +193,114 @@ export function embedAuthedFetch(url: string, init: RequestInit = {}): Promise<R
|
|
|
69
193
|
}
|
|
70
194
|
}
|
|
71
195
|
|
|
72
|
-
|
|
73
|
-
|
|
196
|
+
return fetchWithRefresh(url, init, baseHeaders, false)
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Single in-flight refresh shared across all concurrent `embedAuthedFetch`
|
|
201
|
+
* callers. A stampede of chat requests that all 401 at the same moment must
|
|
202
|
+
* trigger the adapter's `refresh()` ONCE, not N times — otherwise an
|
|
203
|
+
* expiring session fires a thundering herd of refresh calls at the auth
|
|
204
|
+
* server. Resets to `null` once settled so the next genuine expiry can
|
|
205
|
+
* refresh again.
|
|
206
|
+
*/
|
|
207
|
+
// Stored on `globalThis` rather than a module-local so the "single refresh"
|
|
208
|
+
// guarantee survives module duplication. Bundlers can ship more than one copy
|
|
209
|
+
// of this module (e.g. across chunks or a host + embedded build); a per-module
|
|
210
|
+
// variable would let each copy run its own refresh cycle, re-creating the
|
|
211
|
+
// thundering-herd this dedupe exists to prevent.
|
|
212
|
+
const IN_FLIGHT_REFRESH_GLOBAL_KEY = '__embedAuthedFetchInFlightRefresh__'
|
|
213
|
+
|
|
214
|
+
function getInFlightRefresh(): Promise<boolean> | null {
|
|
215
|
+
if (typeof globalThis === 'undefined') return null
|
|
216
|
+
return (
|
|
217
|
+
((globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] as
|
|
218
|
+
| Promise<boolean>
|
|
219
|
+
| null
|
|
220
|
+
| undefined) ?? null
|
|
221
|
+
)
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function setInFlightRefresh(refresh: Promise<boolean> | null): void {
|
|
225
|
+
if (typeof globalThis === 'undefined') return
|
|
226
|
+
;(globalThis as Record<string, unknown>)[IN_FLIGHT_REFRESH_GLOBAL_KEY] = refresh
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function dedupedRefresh(): Promise<boolean> {
|
|
230
|
+
const adapter = getRegisteredAuthAdapter()
|
|
231
|
+
if (!adapter?.refresh) return Promise.resolve(false)
|
|
232
|
+
let inFlightRefresh = getInFlightRefresh()
|
|
233
|
+
if (!inFlightRefresh) {
|
|
234
|
+
// Wrap in `Promise.resolve` so an adapter that throws synchronously
|
|
235
|
+
// (rather than rejecting) still funnels through the shared slot and
|
|
236
|
+
// clears it. A rejected refresh is treated as "could not refresh".
|
|
237
|
+
inFlightRefresh = Promise.resolve()
|
|
238
|
+
.then(() => adapter.refresh!())
|
|
239
|
+
.catch(() => false)
|
|
240
|
+
.finally(() => {
|
|
241
|
+
setInFlightRefresh(null)
|
|
242
|
+
})
|
|
243
|
+
setInFlightRefresh(inFlightRefresh)
|
|
244
|
+
}
|
|
245
|
+
return inFlightRefresh
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Core fetch path: merge proxy-auth + adapter headers, issue the request,
|
|
250
|
+
* and — on a `401` with a refresh-capable adapter — refresh once and retry
|
|
251
|
+
* the identical request a single time. Mirrors the openframe `apiClient`'s
|
|
252
|
+
* refresh-then-retry contract (`isRetry` guards against infinite loops).
|
|
253
|
+
*/
|
|
254
|
+
async function fetchWithRefresh(
|
|
255
|
+
url: string,
|
|
256
|
+
init: RequestInit,
|
|
257
|
+
baseHeaders: Record<string, string>,
|
|
258
|
+
isRetry: boolean,
|
|
259
|
+
): Promise<Response> {
|
|
260
|
+
// Re-run the merge each attempt: `applyProxyAuth` reads the latest stored
|
|
261
|
+
// proxy creds and `getHeaders()` reads the latest bearer, so a retry after
|
|
262
|
+
// refresh carries the rotated token rather than the stale one. `{...baseHeaders}`
|
|
263
|
+
// keeps the caller's normalized headers immutable across attempts.
|
|
264
|
+
const { url: authedUrl, headers } = applyProxyAuth(url, { ...baseHeaders })
|
|
265
|
+
|
|
266
|
+
// Host-supplied auth adapter layer. Runs AFTER the proxy-auth merge so
|
|
267
|
+
// adapter headers override both caller and proxy values — the adapter
|
|
268
|
+
// is the host's explicit "this is my auth model" override, intentionally
|
|
269
|
+
// last-writer-wins. When no adapter is registered, this is a zero-cost
|
|
270
|
+
// no-op (object spread of `{}`).
|
|
271
|
+
const adapter = getRegisteredAuthAdapter()
|
|
272
|
+
if (adapter?.getHeaders) {
|
|
273
|
+
// Filter `undefined` values — the adapter type allows them so consumers
|
|
274
|
+
// don't have to narrow `{ Authorization: token ? '…' : undefined }`-shaped
|
|
275
|
+
// returns, but `fetch` headers must be strings.
|
|
276
|
+
for (const [k, v] of Object.entries(adapter.getHeaders())) {
|
|
277
|
+
if (v !== undefined) headers[k] = v
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
const credentials = adapter?.credentials ?? init.credentials ?? 'same-origin'
|
|
281
|
+
|
|
282
|
+
const response = await fetch(authedUrl, {
|
|
74
283
|
...init,
|
|
75
284
|
headers,
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
credentials:
|
|
285
|
+
// Default `same-origin` carries Supabase cookies for the MPH proxy-
|
|
286
|
+
// auth model. Hosts on different origins (openframe-frontend ↔
|
|
287
|
+
// openframe gateway) register `credentials: 'include'` via the
|
|
288
|
+
// adapter to make their own cookies travel cross-origin (CORS +
|
|
289
|
+
// `SameSite=None` must be configured server-side for that to work).
|
|
290
|
+
credentials,
|
|
79
291
|
})
|
|
292
|
+
|
|
293
|
+
// 401 self-heal: refresh the token once and retry. Only when an adapter
|
|
294
|
+
// opted into `refresh`, and only on the first attempt — a 401 on the
|
|
295
|
+
// retry means the fresh token is also unauthorized, so surface it.
|
|
296
|
+
if (response.status === 401 && !isRetry && adapter?.refresh) {
|
|
297
|
+
const refreshed = await dedupedRefresh()
|
|
298
|
+
if (refreshed) {
|
|
299
|
+
return fetchWithRefresh(url, init, baseHeaders, true)
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
return response
|
|
80
304
|
}
|
|
81
305
|
|
|
82
306
|
/**
|
|
@@ -118,6 +342,22 @@ function assertSameOrigin(url: string): void {
|
|
|
118
342
|
)
|
|
119
343
|
}
|
|
120
344
|
if (target.origin !== pageOrigin) {
|
|
345
|
+
// Dev-mode escape hatch — embedded apps (e.g. openframe-frontend)
|
|
346
|
+
// run on a different origin from their gateway during local dev,
|
|
347
|
+
// and forcing a Next.js `rewrites()` workaround is more error-prone
|
|
348
|
+
// than relaxing the guard for the dev build. In production
|
|
349
|
+
// (`NODE_ENV === 'production'`) the guard stays absolute — same
|
|
350
|
+
// defense-in-depth bearer-leak protection as before. The check is
|
|
351
|
+
// baked at build time by Next/webpack/Turbopack so prod bundles
|
|
352
|
+
// contain only the throwing branch (no dev string in the artifact).
|
|
353
|
+
if (process.env.NODE_ENV !== 'production') {
|
|
354
|
+
console.warn(
|
|
355
|
+
`[embedAuthedFetch] cross-origin fetch to ${target.origin} ` +
|
|
356
|
+
`allowed in dev (NODE_ENV !== 'production'). Production builds ` +
|
|
357
|
+
`will reject this — wire a same-origin proxy before shipping.`,
|
|
358
|
+
)
|
|
359
|
+
return
|
|
360
|
+
}
|
|
121
361
|
throw new Error(
|
|
122
362
|
`embedAuthedFetch: refusing cross-origin fetch to ${target.origin} — pass a relative /api/* path instead`,
|
|
123
363
|
)
|
package/src/utils/index.ts
CHANGED
|
@@ -120,7 +120,11 @@ export {
|
|
|
120
120
|
// surfaces from importing a chat-prefixed symbol. Old chat-prefixed
|
|
121
121
|
// aliases are kept as @deprecated re-exports at
|
|
122
122
|
// `components/chat/utils/chat-authed-fetch.ts` + `chat-proxy-auth-storage.ts`.
|
|
123
|
-
export {
|
|
123
|
+
export {
|
|
124
|
+
embedAuthedFetch,
|
|
125
|
+
setEmbedAuthAdapter,
|
|
126
|
+
type EmbedAuthAdapter,
|
|
127
|
+
} from './embed-authed-fetch'
|
|
124
128
|
export {
|
|
125
129
|
type EmbedProxyAuth,
|
|
126
130
|
getEmbedProxyAuth,
|
|
@@ -1,74 +1,148 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `scrollElementIntoView` — canonical "
|
|
3
|
-
*
|
|
4
|
-
* known layout shifts" helper.
|
|
2
|
+
* `scrollElementIntoView` — canonical "scroll an element to the top of the
|
|
3
|
+
* viewport, account for sticky chrome, survive layout shifts" helper.
|
|
5
4
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* One shared implementation so every caller (the ticket drawer expand, the
|
|
6
|
+
* hub's `useUnifiedNav` / `use-nav-link` hash scroll, doc-tree, delivery
|
|
7
|
+
* `?focus=`, sticky-section-nav, …) inherits the SAME cancellation-proof
|
|
8
|
+
* motion.
|
|
8
9
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* - Future ticket-row / docs anchor scrolls
|
|
10
|
+
* WHY A SELF-DRIVEN rAF TWEEN INSTEAD OF `window.scrollTo({behavior:'smooth'})`:
|
|
11
|
+
* the native smooth scroll is CANCELLABLE, and in real pages it gets cancelled
|
|
12
|
+
* constantly:
|
|
13
13
|
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
14
|
+
* - Browser SCROLL ANCHORING: when content is inserted/removed above or
|
|
15
|
+
* around the target (a collapsible drawer expanding, an async image
|
|
16
|
+
* loading, a list re-rendering) the browser issues a synchronous scrollTop
|
|
17
|
+
* correction to keep the anchored element stable. Per CSSOM-View "perform a
|
|
18
|
+
* scroll" step 1 ("abort any ongoing smooth scroll"), that correction
|
|
19
|
+
* ABORTS an in-flight native smooth scroll — so it lands as an instant jump.
|
|
20
|
+
* Anchoring is suppressed when the scroll offset is 0, which is exactly why
|
|
21
|
+
* a native smooth scroll appears to work the FIRST time (page at top) and
|
|
22
|
+
* jumps on every repeat (page already scrolled). This was a multi-day
|
|
23
|
+
* "smooth only works once" bug on the /tickets drawer.
|
|
24
|
+
* - A second programmatic scroll on the same frame, or a `focus()` without
|
|
25
|
+
* `{preventScroll:true}`, cancels it the same way.
|
|
21
26
|
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* A tween that re-asserts the position with INSTANT writes every frame is
|
|
28
|
+
* immune: there is no "ongoing native smooth scroll" for anchoring/focus to
|
|
29
|
+
* abort, and any correction that lands between our frames is overwritten on the
|
|
30
|
+
* next frame. We also RECOMPUTE the target each frame, so an element whose
|
|
31
|
+
* final position is still settling (drawer still expanding, images loading)
|
|
32
|
+
* is tracked to its resting place instead of animating to a stale pixel.
|
|
33
|
+
*
|
|
34
|
+
* Honors `prefers-reduced-motion` (jumps instantly) and cancels on genuine user
|
|
35
|
+
* scroll intent (wheel / touch) so we never fight the user.
|
|
30
36
|
*/
|
|
31
37
|
|
|
32
38
|
export interface ScrollElementIntoViewOptions {
|
|
33
|
-
/** Pixels to subtract from the target element's `top` so it lands
|
|
34
|
-
*
|
|
35
|
-
* `scroll-mt-24`) for the standard hub header offset. */
|
|
39
|
+
/** Pixels to subtract from the target element's `top` so it lands BELOW
|
|
40
|
+
* sticky chrome. Defaults to 0. Pass `96` for the standard hub header. */
|
|
36
41
|
headerOffset?: number
|
|
37
|
-
/**
|
|
38
|
-
*
|
|
39
|
-
* link land, programmatic focus moves). */
|
|
42
|
+
/** `'smooth'` (default) runs the self-driven tween; `'instant'` / `'auto'`
|
|
43
|
+
* jump in one synchronous write (deep-link land, programmatic focus moves). */
|
|
40
44
|
behavior?: ScrollBehavior
|
|
41
|
-
/** Optional adjustment applied to the computed pixel target. The
|
|
42
|
-
* callback receives the "raw" Y (`element.top + scrollY -
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
* between the call and the animation completing — the browser's
|
|
46
|
-
* smooth-scroll commits to a single pixel value, so providing the
|
|
47
|
-
* post-shift target up front lands the element correctly even as
|
|
48
|
-
* content above it moves. */
|
|
45
|
+
/** Optional adjustment applied to the computed pixel target each frame. The
|
|
46
|
+
* callback receives the "raw" Y (`element.top + scrollY - headerOffset`) and
|
|
47
|
+
* returns the FINAL target. Use when the caller knows about a layout shift
|
|
48
|
+
* (e.g. a sibling drawer collapsing) the geometry can't yet reflect. */
|
|
49
49
|
adjustTargetY?: (rawTargetY: number) => number
|
|
50
|
+
/** Tween duration in ms (smooth only). Default 320. */
|
|
51
|
+
durationMs?: number
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Module-level handle to the in-flight tween so a new call (or a user
|
|
55
|
+
* gesture) cancels the previous one — only ever one page-scroll animation at
|
|
56
|
+
* a time. */
|
|
57
|
+
let activeRaf = 0
|
|
58
|
+
let teardownActive: (() => void) | null = null
|
|
59
|
+
|
|
60
|
+
function cancelActiveScroll(): void {
|
|
61
|
+
if (activeRaf) {
|
|
62
|
+
cancelAnimationFrame(activeRaf)
|
|
63
|
+
activeRaf = 0
|
|
64
|
+
}
|
|
65
|
+
if (teardownActive) {
|
|
66
|
+
teardownActive()
|
|
67
|
+
teardownActive = null
|
|
68
|
+
}
|
|
50
69
|
}
|
|
51
70
|
|
|
71
|
+
const easeOutCubic = (t: number): number => 1 - Math.pow(1 - t, 3)
|
|
72
|
+
|
|
52
73
|
/**
|
|
53
|
-
* Scroll the page so `target` lands at the top of the viewport
|
|
54
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
* Accepts:
|
|
58
|
-
* - `HTMLElement` — direct reference (most common from `useRef`).
|
|
59
|
-
* - `null` / `undefined` — no-op so callers can pass refs without
|
|
60
|
-
* defensive branching.
|
|
61
|
-
*
|
|
62
|
-
* SSR-safe: short-circuits when `window` is undefined.
|
|
74
|
+
* Scroll the page so `target` lands at the top of the viewport (below sticky
|
|
75
|
+
* chrome via `headerOffset`). SSR-safe; `null`/`undefined` target is a no-op so
|
|
76
|
+
* callers can pass refs without defensive branching.
|
|
63
77
|
*/
|
|
64
78
|
export function scrollElementIntoView(
|
|
65
79
|
target: HTMLElement | null | undefined,
|
|
66
80
|
options: ScrollElementIntoViewOptions = {},
|
|
67
81
|
): void {
|
|
68
82
|
if (typeof window === 'undefined' || !target) return
|
|
69
|
-
const { headerOffset = 0, behavior = 'smooth', adjustTargetY } = options
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
83
|
+
const { headerOffset = 0, behavior = 'smooth', adjustTargetY, durationMs = 320 } = options
|
|
84
|
+
|
|
85
|
+
// Target is recomputed every frame: the row's absolute position can move as
|
|
86
|
+
// the page reflows (a sibling drawer collapsing) and the reachable max grows
|
|
87
|
+
// as the just-opened drawer expands. Clamp to the LIVE max each frame.
|
|
88
|
+
const computeTarget = (): number => {
|
|
89
|
+
const raw = target.getBoundingClientRect().top + window.scrollY - headerOffset
|
|
90
|
+
const adjusted = adjustTargetY ? adjustTargetY(raw) : raw
|
|
91
|
+
const maxScroll = Math.max(
|
|
92
|
+
0,
|
|
93
|
+
document.documentElement.scrollHeight - window.innerHeight,
|
|
94
|
+
)
|
|
95
|
+
return Math.min(Math.max(0, adjusted), maxScroll)
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Any prior animation loses — one page scroll at a time.
|
|
99
|
+
cancelActiveScroll()
|
|
100
|
+
|
|
101
|
+
const prefersReduced =
|
|
102
|
+
typeof window.matchMedia === 'function' &&
|
|
103
|
+
window.matchMedia('(prefers-reduced-motion: reduce)').matches
|
|
104
|
+
|
|
105
|
+
// Instant paths: a single synchronous write. No tween, no anchoring race.
|
|
106
|
+
if (behavior === 'instant' || behavior === 'auto' || prefersReduced) {
|
|
107
|
+
window.scrollTo(0, computeTarget())
|
|
108
|
+
return
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Smooth: self-driven tween with instant per-frame writes (anchoring-proof).
|
|
112
|
+
let startY: number | null = null
|
|
113
|
+
let startTime = 0
|
|
114
|
+
|
|
115
|
+
// Bail the moment the user takes over with a real scroll gesture — we must
|
|
116
|
+
// never fight them. (Not keydown: the ticket composer auto-focuses on open,
|
|
117
|
+
// and typing there should not abort the scroll.)
|
|
118
|
+
const onUserGesture = () => cancelActiveScroll()
|
|
119
|
+
window.addEventListener('wheel', onUserGesture, { passive: true })
|
|
120
|
+
window.addEventListener('touchmove', onUserGesture, { passive: true })
|
|
121
|
+
teardownActive = () => {
|
|
122
|
+
window.removeEventListener('wheel', onUserGesture)
|
|
123
|
+
window.removeEventListener('touchmove', onUserGesture)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const step = (now: number) => {
|
|
127
|
+
if (startY === null) {
|
|
128
|
+
startY = window.scrollY
|
|
129
|
+
startTime = now
|
|
130
|
+
}
|
|
131
|
+
const targetY = computeTarget()
|
|
132
|
+
const t = Math.min(1, (now - startTime) / durationMs)
|
|
133
|
+
const y = startY + (targetY - startY) * easeOutCubic(t)
|
|
134
|
+
window.scrollTo(0, y)
|
|
135
|
+
if (t < 1) {
|
|
136
|
+
activeRaf = requestAnimationFrame(step)
|
|
137
|
+
} else {
|
|
138
|
+
// Final exact write in case easing left a sub-pixel gap, then teardown.
|
|
139
|
+
window.scrollTo(0, computeTarget())
|
|
140
|
+
activeRaf = 0
|
|
141
|
+
if (teardownActive) {
|
|
142
|
+
teardownActive()
|
|
143
|
+
teardownActive = null
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
activeRaf = requestAnimationFrame(step)
|
|
74
148
|
}
|