@byline/host-tanstack-start 3.20.4 → 4.0.0
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/admin-shell/admin-activity/list.js +15 -5
- package/dist/admin-shell/admin-roles/delete.js +2 -1
- package/dist/admin-shell/admin-roles/list.js +2 -1
- package/dist/admin-shell/admin-users/delete.js +2 -1
- package/dist/admin-shell/admin-users/list.js +4 -7
- package/dist/admin-shell/chrome/admin-app-bar.js +2 -1
- package/dist/admin-shell/chrome/admin-menu-drawer.js +9 -8
- package/dist/admin-shell/chrome/admin-menu-provider.js +2 -1
- package/dist/admin-shell/chrome/dashboard.js +4 -3
- package/dist/admin-shell/chrome/sign-in-page.d.ts +2 -2
- package/dist/admin-shell/chrome/sign-in-page.js +2 -2
- package/dist/admin-shell/collections/create.js +4 -3
- package/dist/admin-shell/collections/document-history.js +6 -1
- package/dist/admin-shell/collections/edit.js +18 -14
- package/dist/admin-shell/collections/history.js +5 -4
- package/dist/admin-shell/collections/list.js +8 -7
- package/dist/admin-shell/collections/restore-version-modal.js +2 -1
- package/dist/admin-shell/collections/tree-list.js +53 -17
- package/dist/admin-shell/collections/view-menu.js +9 -8
- package/dist/auth/auth-context.d.ts +13 -0
- package/dist/auth/auth-context.js +4 -0
- package/dist/auth/request-scope.d.ts +16 -0
- package/dist/auth/request-scope.js +24 -0
- package/dist/integrations/abilities.js +8 -7
- package/dist/integrations/byline-public-client.d.ts +38 -0
- package/dist/integrations/byline-public-client.js +8 -4
- package/dist/integrations/byline-viewer-client.d.ts +6 -0
- package/dist/integrations/byline-viewer-client.js +22 -17
- package/dist/integrations/start-errors.js +2 -1
- package/dist/routes/admin-path.d.ts +22 -0
- package/dist/routes/admin-path.js +35 -0
- package/dist/routes/create-admin-account-route.js +3 -2
- package/dist/routes/create-admin-activity-route.js +3 -2
- package/dist/routes/create-admin-dashboard-route.js +2 -1
- package/dist/routes/create-admin-layout-route.d.ts +1 -1
- package/dist/routes/create-admin-layout-route.js +3 -1
- package/dist/routes/create-admin-permissions-route.js +3 -2
- package/dist/routes/create-admin-role-edit-route.js +4 -3
- package/dist/routes/create-admin-roles-list-route.js +3 -2
- package/dist/routes/create-admin-user-edit-route.js +4 -3
- package/dist/routes/create-admin-users-list-route.js +3 -2
- package/dist/routes/create-collection-api-route.d.ts +1 -7
- package/dist/routes/create-collection-api-route.js +12 -8
- package/dist/routes/create-collection-create-route.js +4 -3
- package/dist/routes/create-collection-edit-route.d.ts +1 -7
- package/dist/routes/create-collection-edit-route.js +9 -6
- package/dist/routes/create-collection-history-route.d.ts +1 -7
- package/dist/routes/create-collection-history-route.js +10 -7
- package/dist/routes/create-collection-list-route.js +3 -2
- package/dist/routes/create-sign-in-route.js +3 -2
- package/dist/routes/get-content-locale-route-config.d.ts +12 -0
- package/dist/routes/get-content-locale-route-config.js +16 -0
- package/dist/routes/index.d.ts +3 -0
- package/dist/routes/index.js +2 -0
- package/dist/routes/sign-in-path.d.ts +13 -0
- package/dist/routes/sign-in-path.js +19 -0
- package/dist/server-fns/collections/delete-outcome.d.ts +30 -0
- package/dist/server-fns/collections/delete-outcome.js +34 -0
- package/dist/server-fns/collections/delete.d.ts +1 -4
- package/dist/server-fns/collections/delete.js +2 -4
- package/dist/server-fns/collections/index.d.ts +1 -0
- package/dist/server-fns/collections/index.js +1 -0
- package/dist/server-fns/collections/tree-mutation.d.ts +46 -0
- package/dist/server-fns/collections/tree-mutation.js +109 -0
- package/dist/server-fns/collections/tree-read.d.ts +6 -0
- package/dist/server-fns/collections/tree-read.js +15 -0
- package/dist/server-fns/collections/tree.d.ts +5 -4
- package/dist/server-fns/collections/tree.js +15 -12
- package/dist/server-fns/collections/update.js +2 -1
- package/dist/vite/client-hook-boundary.d.ts +19 -0
- package/dist/vite/client-hook-boundary.js +20 -0
- package/dist/vite/index.d.ts +8 -0
- package/dist/vite/index.js +1 -0
- package/package.json +15 -9
- package/src/admin-shell/admin-activity/list.tsx +13 -3
- package/src/admin-shell/admin-roles/delete.tsx +2 -1
- package/src/admin-shell/admin-roles/list.tsx +2 -1
- package/src/admin-shell/admin-users/delete.tsx +3 -2
- package/src/admin-shell/admin-users/list.tsx +9 -8
- package/src/admin-shell/chrome/admin-app-bar.tsx +2 -1
- package/src/admin-shell/chrome/admin-menu-drawer.tsx +13 -8
- package/src/admin-shell/chrome/admin-menu-provider.tsx +2 -1
- package/src/admin-shell/chrome/dashboard.tsx +4 -3
- package/src/admin-shell/chrome/sign-in-page.tsx +3 -3
- package/src/admin-shell/collections/create.tsx +4 -3
- package/src/admin-shell/collections/document-history.tsx +5 -0
- package/src/admin-shell/collections/edit.tsx +20 -13
- package/src/admin-shell/collections/history.tsx +6 -5
- package/src/admin-shell/collections/list.tsx +8 -7
- package/src/admin-shell/collections/restore-version-modal.tsx +2 -1
- package/src/admin-shell/collections/tree-list.tsx +78 -22
- package/src/admin-shell/collections/view-menu.tsx +9 -8
- package/src/auth/auth-context.test.node.ts +87 -0
- package/src/auth/auth-context.ts +18 -0
- package/src/auth/request-scope.test.node.ts +93 -0
- package/src/auth/request-scope.ts +62 -0
- package/src/integrations/abilities.tsx +14 -4
- package/src/integrations/byline-client.ts +6 -4
- package/src/integrations/byline-public-client.ts +18 -4
- package/src/integrations/byline-viewer-client.ts +30 -18
- package/src/integrations/request-context-contract.test.node.ts +131 -0
- package/src/integrations/request-context-factories.test.node.ts +140 -0
- package/src/integrations/start-errors.test.node.ts +22 -0
- package/src/integrations/start-errors.ts +1 -0
- package/src/routes/admin-path.test.node.ts +129 -0
- package/src/routes/admin-path.ts +66 -0
- package/src/routes/auth-routes.test.node.ts +39 -0
- package/src/routes/create-admin-account-route.tsx +3 -2
- package/src/routes/create-admin-activity-route.tsx +3 -2
- package/src/routes/create-admin-dashboard-route.tsx +2 -1
- package/src/routes/create-admin-layout-route.tsx +4 -4
- package/src/routes/create-admin-permissions-route.tsx +3 -2
- package/src/routes/create-admin-role-edit-route.tsx +7 -3
- package/src/routes/create-admin-roles-list-route.tsx +3 -2
- package/src/routes/create-admin-user-edit-route.tsx +7 -3
- package/src/routes/create-admin-users-list-route.tsx +3 -2
- package/src/routes/create-collection-api-route.tsx +15 -14
- package/src/routes/create-collection-create-route.tsx +7 -3
- package/src/routes/create-collection-edit-route.tsx +12 -12
- package/src/routes/create-collection-history-route.tsx +13 -13
- package/src/routes/create-collection-list-route.tsx +3 -2
- package/src/routes/create-sign-in-route.tsx +9 -3
- package/src/routes/get-content-locale-route-config.ts +26 -0
- package/src/routes/index.ts +11 -0
- package/src/routes/sign-in-path.test.node.ts +79 -0
- package/src/routes/sign-in-path.ts +36 -0
- package/src/server-fns/collections/delete-outcome.test.node.ts +72 -0
- package/src/server-fns/collections/delete-outcome.ts +80 -0
- package/src/server-fns/collections/delete.ts +2 -1
- package/src/server-fns/collections/get.ts +2 -0
- package/src/server-fns/collections/index.ts +1 -0
- package/src/server-fns/collections/tree-mutation.test.node.ts +225 -0
- package/src/server-fns/collections/tree-mutation.ts +165 -0
- package/src/server-fns/collections/tree-read.test.node.ts +46 -0
- package/src/server-fns/collections/tree-read.ts +26 -0
- package/src/server-fns/collections/tree.ts +26 -10
- package/src/server-fns/collections/update.ts +3 -0
- package/src/vite/client-hook-boundary.test.node.ts +50 -0
- package/src/vite/client-hook-boundary.ts +45 -0
- package/src/vite/index.ts +9 -0
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import type React from 'react'
|
|
10
|
-
import { useEffect, useMemo, useState } from 'react'
|
|
10
|
+
import { useEffect, useMemo, useRef, useState } from 'react'
|
|
11
11
|
import { useRouter } from '@tanstack/react-router'
|
|
12
12
|
|
|
13
13
|
import { renderFormatted, StatusBadge } from '@byline/admin/react'
|
|
@@ -41,6 +41,12 @@ import {
|
|
|
41
41
|
} from '@dnd-kit/sortable'
|
|
42
42
|
import cx from 'classnames'
|
|
43
43
|
|
|
44
|
+
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
45
|
+
import {
|
|
46
|
+
createAdminTreeMoveController,
|
|
47
|
+
getAdminTreeMoveUiEffects,
|
|
48
|
+
shouldSyncAdminTreeRows,
|
|
49
|
+
} from '../../server-fns/collections/tree-mutation.js'
|
|
44
50
|
import { Link } from '../chrome/loose-router.js'
|
|
45
51
|
import { TableHeadingCellSortable } from '../chrome/th-sortable.js'
|
|
46
52
|
import { formatNumber } from '../chrome/utils.js'
|
|
@@ -59,7 +65,6 @@ export type TreeMoveFn = (params: {
|
|
|
59
65
|
afterDocumentId: string | null
|
|
60
66
|
}) => Promise<unknown>
|
|
61
67
|
|
|
62
|
-
// biome-ignore lint/suspicious/noExplicitAny: tree rows carry heterogeneous fields
|
|
63
68
|
function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
|
|
64
69
|
if (row.fields && fieldName in row.fields) return row.fields[fieldName]
|
|
65
70
|
return (row as Record<string, any>)[fieldName]
|
|
@@ -73,19 +78,20 @@ function getColumnValue(row: CollectionTreeRow, fieldName: string): any {
|
|
|
73
78
|
*/
|
|
74
79
|
function SortableTreeRow({
|
|
75
80
|
id,
|
|
76
|
-
depth,
|
|
77
81
|
dragging,
|
|
82
|
+
disabled,
|
|
78
83
|
dragHandleLabel,
|
|
79
84
|
children,
|
|
80
85
|
}: {
|
|
81
86
|
id: string
|
|
82
|
-
depth: number
|
|
83
87
|
dragging: boolean
|
|
88
|
+
disabled: boolean
|
|
84
89
|
dragHandleLabel: string
|
|
85
90
|
children: React.ReactNode
|
|
86
91
|
}) {
|
|
87
92
|
const { setNodeRef, attributes, listeners, transform, transition, isDragging } = useSortable({
|
|
88
93
|
id,
|
|
94
|
+
disabled,
|
|
89
95
|
})
|
|
90
96
|
const style: React.CSSProperties = {
|
|
91
97
|
transform: transform ? `translate3d(0, ${transform.y}px, 0)` : undefined,
|
|
@@ -103,6 +109,7 @@ function SortableTreeRow({
|
|
|
103
109
|
<td className={cx('byline-tree-list-drag-cell', styles.dragCell)}>
|
|
104
110
|
<button
|
|
105
111
|
type="button"
|
|
112
|
+
disabled={disabled}
|
|
106
113
|
className={cx('byline-tree-list-drag-handle', styles.dragHandle)}
|
|
107
114
|
aria-label={dragHandleLabel}
|
|
108
115
|
{...attributes}
|
|
@@ -149,8 +156,12 @@ export const TreeListView = ({
|
|
|
149
156
|
// back). Mirrors the flat-list reorder pattern.
|
|
150
157
|
const [localPlaced, setLocalPlaced] = useState(() => rows.filter((r) => !r.unplaced))
|
|
151
158
|
const [isMoving, setIsMoving] = useState(false)
|
|
159
|
+
const [moveController] = useState(() => createAdminTreeMoveController())
|
|
160
|
+
const preserveOptimisticForRows = useRef<CollectionTreeRow[] | null>(null)
|
|
152
161
|
useEffect(() => {
|
|
153
|
-
if (!
|
|
162
|
+
if (!shouldSyncAdminTreeRows(rows, preserveOptimisticForRows.current, isMoving)) return
|
|
163
|
+
preserveOptimisticForRows.current = null
|
|
164
|
+
setLocalPlaced(rows.filter((r) => !r.unplaced))
|
|
154
165
|
}, [rows, isMoving])
|
|
155
166
|
|
|
156
167
|
const [activeId, setActiveId] = useState<string | null>(null)
|
|
@@ -174,11 +185,17 @@ export const TreeListView = ({
|
|
|
174
185
|
setOffsetLeft(0)
|
|
175
186
|
}
|
|
176
187
|
|
|
177
|
-
const handleDragStart = ({ active }: DragStartEvent) =>
|
|
188
|
+
const handleDragStart = ({ active }: DragStartEvent) => {
|
|
189
|
+
if (!moveController.isMoving()) setActiveId(String(active.id))
|
|
190
|
+
}
|
|
178
191
|
const handleDragMove = ({ delta }: DragMoveEvent) => setOffsetLeft(delta.x)
|
|
179
192
|
const handleDragOver = ({ over }: DragOverEvent) => setOverId(over ? String(over.id) : null)
|
|
180
193
|
|
|
181
194
|
const handleDragEnd = async ({ active, over }: DragEndEvent) => {
|
|
195
|
+
if (moveController.isMoving()) {
|
|
196
|
+
resetDnd()
|
|
197
|
+
return
|
|
198
|
+
}
|
|
182
199
|
const proj =
|
|
183
200
|
over != null
|
|
184
201
|
? getTreeProjection(localPlaced, String(active.id), String(over.id), offsetLeft, INDENT)
|
|
@@ -197,24 +214,60 @@ export const TreeListView = ({
|
|
|
197
214
|
|
|
198
215
|
setLocalPlaced(next)
|
|
199
216
|
setIsMoving(true)
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
217
|
+
const outcome = await moveController.execute(
|
|
218
|
+
() =>
|
|
219
|
+
onMove({
|
|
220
|
+
documentId,
|
|
221
|
+
parentDocumentId: proj.parentId,
|
|
222
|
+
beforeDocumentId: proj.beforeId,
|
|
223
|
+
afterDocumentId: proj.afterId,
|
|
224
|
+
}),
|
|
225
|
+
() => router.invalidate()
|
|
226
|
+
)
|
|
227
|
+
|
|
228
|
+
if (outcome.status === 'suppressed') {
|
|
209
229
|
setLocalPlaced(current)
|
|
230
|
+
setIsMoving(moveController.isMoving())
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const effects = getAdminTreeMoveUiEffects(outcome)
|
|
235
|
+
if (effects.rollback) setLocalPlaced(current)
|
|
236
|
+
|
|
237
|
+
if (effects.structuralFailure != null) {
|
|
238
|
+
const conflict = effects.structuralFailure === 'conflict'
|
|
210
239
|
toastManager.add({
|
|
211
|
-
title: t(
|
|
212
|
-
|
|
240
|
+
title: t(
|
|
241
|
+
conflict ? 'collections.list.treeConflictToast' : 'collections.list.reorderFailedToast'
|
|
242
|
+
),
|
|
243
|
+
description: t(
|
|
244
|
+
conflict
|
|
245
|
+
? 'collections.list.treeConflictDescription'
|
|
246
|
+
: 'collections.list.reorderFailedDescription'
|
|
247
|
+
),
|
|
213
248
|
data: { intent: 'danger', iconType: 'danger', icon: true, close: true },
|
|
214
249
|
})
|
|
215
|
-
} finally {
|
|
216
|
-
setIsMoving(false)
|
|
217
250
|
}
|
|
251
|
+
|
|
252
|
+
if (effects.committedHookWarning) {
|
|
253
|
+
toastManager.add({
|
|
254
|
+
title: t('collections.list.treeHookFailedToast'),
|
|
255
|
+
description: t('collections.list.treeHookFailedDescription'),
|
|
256
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
if (effects.refreshWarning) {
|
|
261
|
+
toastManager.add({
|
|
262
|
+
title: t('collections.list.treeRefreshFailedToast'),
|
|
263
|
+
description: t('collections.list.treeRefreshFailedDescription'),
|
|
264
|
+
data: { intent: 'warning', iconType: 'warning', icon: true, close: true },
|
|
265
|
+
})
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (effects.preserveOptimistic) preserveOptimisticForRows.current = rows
|
|
269
|
+
|
|
270
|
+
setIsMoving(false)
|
|
218
271
|
}
|
|
219
272
|
|
|
220
273
|
const renderCells = (row: CollectionTreeRow, depth: number) =>
|
|
@@ -240,7 +293,7 @@ export const TreeListView = ({
|
|
|
240
293
|
</span>
|
|
241
294
|
)}
|
|
242
295
|
<Link
|
|
243
|
-
to={'
|
|
296
|
+
to={getAdminRoutePath('collections', '$collection', '$id')}
|
|
244
297
|
params={{ collection, id: row.id }}
|
|
245
298
|
>
|
|
246
299
|
{column.formatter
|
|
@@ -277,7 +330,10 @@ export const TreeListView = ({
|
|
|
277
330
|
<IconButton
|
|
278
331
|
aria-label={t('collections.list.createAriaLabel')}
|
|
279
332
|
render={
|
|
280
|
-
<Link
|
|
333
|
+
<Link
|
|
334
|
+
to={getAdminRoutePath('collections', '$collection', 'create')}
|
|
335
|
+
params={{ collection }}
|
|
336
|
+
/>
|
|
281
337
|
}
|
|
282
338
|
>
|
|
283
339
|
<PlusIcon height="18px" width="18px" svgClassName="stroke-white" />
|
|
@@ -323,8 +379,8 @@ export const TreeListView = ({
|
|
|
323
379
|
<SortableTreeRow
|
|
324
380
|
key={row.id}
|
|
325
381
|
id={row.id}
|
|
326
|
-
depth={depth}
|
|
327
382
|
dragging={row.id === activeId}
|
|
383
|
+
disabled={isMoving}
|
|
328
384
|
dragHandleLabel={t('collections.list.dragHandleAriaLabel')}
|
|
329
385
|
>
|
|
330
386
|
{renderCells(row, depth)}
|
|
@@ -13,6 +13,7 @@ import { useTranslation } from '@byline/i18n/react'
|
|
|
13
13
|
import { Button, HistoryIcon, IconButton, Label, Select } from '@byline/ui/react'
|
|
14
14
|
import cx from 'classnames'
|
|
15
15
|
|
|
16
|
+
import { getAdminRoutePath } from '../../routes/admin-path.js'
|
|
16
17
|
import { useNavigate } from '../chrome/loose-router.js'
|
|
17
18
|
import { PreviewLink } from './preview-link.js'
|
|
18
19
|
import styles from './view-menu.module.css'
|
|
@@ -86,7 +87,7 @@ export const ViewMenu = ({
|
|
|
86
87
|
useEffect(() => {
|
|
87
88
|
if (activeView === 'edit' && locale === 'all') {
|
|
88
89
|
navigate({
|
|
89
|
-
to: '
|
|
90
|
+
to: getAdminRoutePath('collections', '$collection', '$id'),
|
|
90
91
|
params: { collection, id: documentId },
|
|
91
92
|
search: (prev: Record<string, unknown>) => ({
|
|
92
93
|
...prev,
|
|
@@ -103,19 +104,19 @@ export const ViewMenu = ({
|
|
|
103
104
|
const search = (prev: Record<string, unknown>) => ({ ...prev, locale: value })
|
|
104
105
|
if (activeView === 'api') {
|
|
105
106
|
navigate({
|
|
106
|
-
to: '
|
|
107
|
+
to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
|
|
107
108
|
params: { collection, id: documentId },
|
|
108
109
|
search: search as never,
|
|
109
110
|
})
|
|
110
111
|
} else if (activeView === 'history') {
|
|
111
112
|
navigate({
|
|
112
|
-
to: '
|
|
113
|
+
to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
|
|
113
114
|
params: { collection, id: documentId },
|
|
114
115
|
search: search as never,
|
|
115
116
|
})
|
|
116
117
|
} else {
|
|
117
118
|
navigate({
|
|
118
|
-
to: '
|
|
119
|
+
to: getAdminRoutePath('collections', '$collection', '$id'),
|
|
119
120
|
params: { collection, id: documentId },
|
|
120
121
|
search: search as never,
|
|
121
122
|
})
|
|
@@ -127,7 +128,7 @@ export const ViewMenu = ({
|
|
|
127
128
|
const n = Number.parseInt(value, 10)
|
|
128
129
|
const nextDepth = Number.isFinite(n) ? Math.max(0, Math.min(3, n)) : 0
|
|
129
130
|
navigate({
|
|
130
|
-
to: '
|
|
131
|
+
to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
|
|
131
132
|
params: { collection, id: documentId },
|
|
132
133
|
// Store 0 as undefined so the URL stays clean when the user picks
|
|
133
134
|
// "no populate" — avoids `?depth=0` in bookmarks / share links.
|
|
@@ -201,7 +202,7 @@ export const ViewMenu = ({
|
|
|
201
202
|
variant={activeView === 'history' ? 'filled' : 'text'}
|
|
202
203
|
onClick={() =>
|
|
203
204
|
navigate({
|
|
204
|
-
to: '
|
|
205
|
+
to: getAdminRoutePath('collections', '$collection', '$id', 'history'),
|
|
205
206
|
params: { collection, id: documentId },
|
|
206
207
|
search: locale ? { locale } : {},
|
|
207
208
|
})
|
|
@@ -215,7 +216,7 @@ export const ViewMenu = ({
|
|
|
215
216
|
className={cx('byline-view-menu-button', styles.button)}
|
|
216
217
|
onClick={() =>
|
|
217
218
|
navigate({
|
|
218
|
-
to: '
|
|
219
|
+
to: getAdminRoutePath('collections', '$collection', '$id'),
|
|
219
220
|
params: { collection, id: documentId },
|
|
220
221
|
search: locale ? { locale } : {},
|
|
221
222
|
})
|
|
@@ -229,7 +230,7 @@ export const ViewMenu = ({
|
|
|
229
230
|
className={cx('byline-view-menu-button', styles.button)}
|
|
230
231
|
onClick={() =>
|
|
231
232
|
navigate({
|
|
232
|
-
to: '
|
|
233
|
+
to: getAdminRoutePath('collections', '$collection', '$id', 'api'),
|
|
233
234
|
params: { collection, id: documentId },
|
|
234
235
|
search: locale ? { locale } : {},
|
|
235
236
|
})
|
|
@@ -28,6 +28,7 @@ const mocks = vi.hoisted(() => ({
|
|
|
28
28
|
getCookie: vi.fn(),
|
|
29
29
|
setCookie: vi.fn(),
|
|
30
30
|
getRequestHeader: vi.fn(),
|
|
31
|
+
getRequest: vi.fn(),
|
|
31
32
|
}))
|
|
32
33
|
|
|
33
34
|
vi.mock('@byline/core', async () => {
|
|
@@ -47,6 +48,7 @@ vi.mock('@tanstack/react-start/server', () => ({
|
|
|
47
48
|
getCookie: mocks.getCookie,
|
|
48
49
|
setCookie: mocks.setCookie,
|
|
49
50
|
getRequestHeader: mocks.getRequestHeader,
|
|
51
|
+
getRequest: mocks.getRequest,
|
|
50
52
|
}))
|
|
51
53
|
|
|
52
54
|
const { verifyAccessToken, refreshSession, getCookie, setCookie } = mocks
|
|
@@ -70,6 +72,12 @@ function stubActor() {
|
|
|
70
72
|
describe('getAdminRequestContext', () => {
|
|
71
73
|
beforeEach(() => {
|
|
72
74
|
vi.clearAllMocks()
|
|
75
|
+
// Default: no Start request in scope, so every call resolves fresh.
|
|
76
|
+
// The per-request memoization tests below override this with a
|
|
77
|
+
// stable request object.
|
|
78
|
+
mocks.getRequest.mockImplementation(() => {
|
|
79
|
+
throw new Error('No StartEvent found in AsyncLocalStorage')
|
|
80
|
+
})
|
|
73
81
|
})
|
|
74
82
|
|
|
75
83
|
afterEach(() => {
|
|
@@ -212,4 +220,83 @@ describe('getAdminRequestContext', () => {
|
|
|
212
220
|
const clears = setCookie.mock.calls.filter((c) => c[2]?.maxAge === 0)
|
|
213
221
|
expect(clears.length).toBe(2)
|
|
214
222
|
})
|
|
223
|
+
|
|
224
|
+
describe('per-request memoization', () => {
|
|
225
|
+
it('returns the same context instance — and requestId — for every call in one request', async () => {
|
|
226
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
227
|
+
cookiesReturn({
|
|
228
|
+
byline_access_token: 'valid-access',
|
|
229
|
+
byline_refresh_token: 'some-refresh',
|
|
230
|
+
})
|
|
231
|
+
verifyAccessToken.mockResolvedValue({ actor: stubActor() })
|
|
232
|
+
|
|
233
|
+
const first = await getAdminRequestContext()
|
|
234
|
+
const second = await getAdminRequestContext()
|
|
235
|
+
|
|
236
|
+
// Reads sharing a ReadContext bind one request authority; the token
|
|
237
|
+
// includes requestId, so it must be stable within a request.
|
|
238
|
+
expect(second).toBe(first)
|
|
239
|
+
expect(second.requestId).toBe(first.requestId)
|
|
240
|
+
expect(verifyAccessToken).toHaveBeenCalledTimes(1)
|
|
241
|
+
})
|
|
242
|
+
|
|
243
|
+
it('resolves independently across different requests', async () => {
|
|
244
|
+
cookiesReturn({
|
|
245
|
+
byline_access_token: 'valid-access',
|
|
246
|
+
byline_refresh_token: 'some-refresh',
|
|
247
|
+
})
|
|
248
|
+
verifyAccessToken.mockResolvedValue({ actor: stubActor() })
|
|
249
|
+
|
|
250
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
251
|
+
const first = await getAdminRequestContext()
|
|
252
|
+
mocks.getRequest.mockReturnValue({ id: 'request-b' })
|
|
253
|
+
const second = await getAdminRequestContext()
|
|
254
|
+
|
|
255
|
+
expect(second).not.toBe(first)
|
|
256
|
+
expect(second.requestId).not.toBe(first.requestId)
|
|
257
|
+
expect(verifyAccessToken).toHaveBeenCalledTimes(2)
|
|
258
|
+
})
|
|
259
|
+
|
|
260
|
+
it('burns at most one refresh rotation per request', async () => {
|
|
261
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
262
|
+
cookiesReturn({
|
|
263
|
+
byline_access_token: 'stale-access',
|
|
264
|
+
byline_refresh_token: 'valid-refresh',
|
|
265
|
+
})
|
|
266
|
+
const actor = stubActor()
|
|
267
|
+
verifyAccessToken.mockRejectedValueOnce(new Error('expired')).mockResolvedValueOnce({ actor })
|
|
268
|
+
refreshSession.mockResolvedValueOnce({
|
|
269
|
+
accessToken: 'fresh-access',
|
|
270
|
+
refreshToken: 'fresh-refresh',
|
|
271
|
+
accessTokenExpiresAt: new Date(Date.now() + 15 * 60_000),
|
|
272
|
+
refreshTokenExpiresAt: new Date(Date.now() + 30 * 24 * 60 * 60_000),
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
const first = await getAdminRequestContext()
|
|
276
|
+
// A second unmemoized call would re-run the refresh dance against the
|
|
277
|
+
// request's stale cookie — now already rotated — and sign the admin out.
|
|
278
|
+
const second = await getAdminRequestContext()
|
|
279
|
+
|
|
280
|
+
expect(second).toBe(first)
|
|
281
|
+
expect(refreshSession).toHaveBeenCalledTimes(1)
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it('memoizes the unauthenticated rejection within one request', async () => {
|
|
285
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
286
|
+
cookiesReturn({
|
|
287
|
+
byline_access_token: 'stale',
|
|
288
|
+
byline_refresh_token: 'bad-refresh',
|
|
289
|
+
})
|
|
290
|
+
verifyAccessToken.mockRejectedValue(new Error('expired'))
|
|
291
|
+
refreshSession.mockRejectedValue(new Error('revoked'))
|
|
292
|
+
|
|
293
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({
|
|
294
|
+
code: AuthErrorCodes.UNAUTHENTICATED,
|
|
295
|
+
})
|
|
296
|
+
await expect(getAdminRequestContext()).rejects.toMatchObject({
|
|
297
|
+
code: AuthErrorCodes.UNAUTHENTICATED,
|
|
298
|
+
})
|
|
299
|
+
expect(refreshSession).toHaveBeenCalledTimes(1)
|
|
300
|
+
})
|
|
301
|
+
})
|
|
215
302
|
})
|
package/src/auth/auth-context.ts
CHANGED
|
@@ -33,6 +33,19 @@
|
|
|
33
33
|
* Burns a refresh-token rotation only when the access token actually
|
|
34
34
|
* fails verification — not on every request. The "one session" UX is
|
|
35
35
|
* the consequence of this helper working invisibly behind each call.
|
|
36
|
+
*
|
|
37
|
+
* Resolution is memoized per request (`oncePerRequest`): every call within
|
|
38
|
+
* one logical request returns the same `RequestContext` instance — same
|
|
39
|
+
* actor snapshot, same `requestId`. Two invariants depend on this:
|
|
40
|
+
*
|
|
41
|
+
* - The read-authorization layer binds each `ReadContext` to one
|
|
42
|
+
* immutable authority token that includes `requestId`; a fresh id per
|
|
43
|
+
* call makes any two reads sharing a `ReadContext` (the admin tree
|
|
44
|
+
* view, hook-threaded nested reads) throw
|
|
45
|
+
* 'ReadContext cannot be reused across request authorities'.
|
|
46
|
+
* - The refresh path rotates the refresh token; a second unmemoized call
|
|
47
|
+
* in the same request would re-run the dance against the
|
|
48
|
+
* already-rotated cookie and sign the admin out.
|
|
36
49
|
*/
|
|
37
50
|
|
|
38
51
|
import { ERR_UNAUTHENTICATED, type RequestContext } from '@byline/auth'
|
|
@@ -45,6 +58,7 @@ import {
|
|
|
45
58
|
readRefreshTokenCookie,
|
|
46
59
|
setSessionCookies,
|
|
47
60
|
} from './auth-cookies.js'
|
|
61
|
+
import { oncePerRequest } from './request-scope.js'
|
|
48
62
|
|
|
49
63
|
function requireSessionProvider() {
|
|
50
64
|
const provider = getServerConfig().sessionProvider
|
|
@@ -58,6 +72,10 @@ function requireSessionProvider() {
|
|
|
58
72
|
}
|
|
59
73
|
|
|
60
74
|
export async function getAdminRequestContext(): Promise<RequestContext> {
|
|
75
|
+
return oncePerRequest('byline:admin-request-context', resolveAdminRequestContext)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
async function resolveAdminRequestContext(): Promise<RequestContext> {
|
|
61
79
|
const provider = requireSessionProvider()
|
|
62
80
|
|
|
63
81
|
const accessToken = readAccessTokenCookie()
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Unit coverage for per-request memoization. Mocks the Start runtime's
|
|
11
|
+
* `getRequest` so "which request am I in" can be simulated: a stable
|
|
12
|
+
* object per request, or a throw when running outside a request.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
16
|
+
|
|
17
|
+
const mocks = vi.hoisted(() => ({
|
|
18
|
+
getRequest: vi.fn(),
|
|
19
|
+
}))
|
|
20
|
+
|
|
21
|
+
vi.mock('@tanstack/react-start/server', () => ({
|
|
22
|
+
getRequest: mocks.getRequest,
|
|
23
|
+
}))
|
|
24
|
+
|
|
25
|
+
import { oncePerRequest } from './request-scope.js'
|
|
26
|
+
|
|
27
|
+
describe('oncePerRequest', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks()
|
|
30
|
+
mocks.getRequest.mockImplementation(() => {
|
|
31
|
+
throw new Error('No StartEvent found in AsyncLocalStorage')
|
|
32
|
+
})
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('resolves the factory once per request per key', async () => {
|
|
36
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
37
|
+
const factory = vi.fn(async () => ({ token: Math.random() }))
|
|
38
|
+
|
|
39
|
+
const first = await oncePerRequest('ctx', factory)
|
|
40
|
+
const second = await oncePerRequest('ctx', factory)
|
|
41
|
+
|
|
42
|
+
expect(second).toBe(first)
|
|
43
|
+
expect(factory).toHaveBeenCalledTimes(1)
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('keys memoization by request identity', async () => {
|
|
47
|
+
const requestA = { id: 'a' }
|
|
48
|
+
const requestB = { id: 'b' }
|
|
49
|
+
const factory = vi.fn(async () => ({ token: Math.random() }))
|
|
50
|
+
|
|
51
|
+
mocks.getRequest.mockReturnValue(requestA)
|
|
52
|
+
const first = await oncePerRequest('ctx', factory)
|
|
53
|
+
mocks.getRequest.mockReturnValue(requestB)
|
|
54
|
+
const second = await oncePerRequest('ctx', factory)
|
|
55
|
+
|
|
56
|
+
expect(second).not.toBe(first)
|
|
57
|
+
expect(factory).toHaveBeenCalledTimes(2)
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('isolates entries by key within one request', async () => {
|
|
61
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
62
|
+
const admin = vi.fn(async () => 'admin-context')
|
|
63
|
+
const publicCtx = vi.fn(async () => 'public-context')
|
|
64
|
+
|
|
65
|
+
await expect(oncePerRequest('admin', admin)).resolves.toBe('admin-context')
|
|
66
|
+
await expect(oncePerRequest('public', publicCtx)).resolves.toBe('public-context')
|
|
67
|
+
await expect(oncePerRequest('admin', admin)).resolves.toBe('admin-context')
|
|
68
|
+
|
|
69
|
+
expect(admin).toHaveBeenCalledTimes(1)
|
|
70
|
+
expect(publicCtx).toHaveBeenCalledTimes(1)
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('memoizes rejections for the remainder of the request', async () => {
|
|
74
|
+
mocks.getRequest.mockReturnValue({ id: 'request-a' })
|
|
75
|
+
const factory = vi.fn(async () => {
|
|
76
|
+
throw new Error('no admin session')
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
await expect(oncePerRequest('ctx', factory)).rejects.toThrow('no admin session')
|
|
80
|
+
await expect(oncePerRequest('ctx', factory)).rejects.toThrow('no admin session')
|
|
81
|
+
expect(factory).toHaveBeenCalledTimes(1)
|
|
82
|
+
})
|
|
83
|
+
|
|
84
|
+
it('runs unmemoized outside the Start runtime', async () => {
|
|
85
|
+
const factory = vi.fn(async () => ({ token: Math.random() }))
|
|
86
|
+
|
|
87
|
+
const first = await oncePerRequest('ctx', factory)
|
|
88
|
+
const second = await oncePerRequest('ctx', factory)
|
|
89
|
+
|
|
90
|
+
expect(second).not.toBe(first)
|
|
91
|
+
expect(factory).toHaveBeenCalledTimes(2)
|
|
92
|
+
})
|
|
93
|
+
})
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* This Source Code is subject to the terms of the Mozilla Public
|
|
3
|
+
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
4
|
+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
|
|
5
|
+
*
|
|
6
|
+
* Copyright (c) Infonomic Company Limited
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Per-request memoization for `RequestContext` factories.
|
|
11
|
+
*
|
|
12
|
+
* `RequestContext.requestId` is documented as "per logical request", and
|
|
13
|
+
* the read-authorization layer binds every `ReadContext` to one immutable
|
|
14
|
+
* request authority whose token includes that id. A factory that mints a
|
|
15
|
+
* fresh context per call breaks both contracts as soon as two reads share
|
|
16
|
+
* a `ReadContext` (the admin tree view, hook-threaded nested reads):
|
|
17
|
+
* the second bind sees a different authority token and throws
|
|
18
|
+
* 'ReadContext cannot be reused across request authorities'.
|
|
19
|
+
*
|
|
20
|
+
* Memoizing on the current Start request makes every context factory
|
|
21
|
+
* request-stable. It also means at most one session verification — and at
|
|
22
|
+
* most one refresh-token rotation — per request, instead of one per call.
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
import { getRequest } from '@tanstack/react-start/server'
|
|
26
|
+
|
|
27
|
+
const requestScopes = new WeakMap<object, Map<string, Promise<unknown>>>()
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve `factory` at most once per HTTP request for a given `key`. The
|
|
31
|
+
* returned promise is memoized on the current Start request — rejections
|
|
32
|
+
* included, since a request's cookies cannot change mid-flight and retrying
|
|
33
|
+
* a failed refresh would burn a second rotation on an already-rotated
|
|
34
|
+
* token. Outside the Start runtime (seed scripts, unit tests, background
|
|
35
|
+
* jobs) there is no request to key on and the factory runs unmemoized.
|
|
36
|
+
*/
|
|
37
|
+
export function oncePerRequest<T>(key: string, factory: () => Promise<T>): Promise<T> {
|
|
38
|
+
const request = currentRequest()
|
|
39
|
+
if (!request) return factory()
|
|
40
|
+
|
|
41
|
+
let scope = requestScopes.get(request)
|
|
42
|
+
if (!scope) {
|
|
43
|
+
scope = new Map()
|
|
44
|
+
requestScopes.set(request, scope)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const existing = scope.get(key)
|
|
48
|
+
if (existing) return existing as Promise<T>
|
|
49
|
+
|
|
50
|
+
const pending = factory()
|
|
51
|
+
scope.set(key, pending)
|
|
52
|
+
return pending
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function currentRequest(): object | undefined {
|
|
56
|
+
try {
|
|
57
|
+
return getRequest()
|
|
58
|
+
} catch {
|
|
59
|
+
// No StartEvent in AsyncLocalStorage — running outside a request.
|
|
60
|
+
return undefined
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -38,15 +38,25 @@
|
|
|
38
38
|
import type React from 'react'
|
|
39
39
|
import { useRouteContext } from '@tanstack/react-router'
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
41
|
+
import { getAdminRouteId } from '../routes/admin-path.js'
|
|
42
|
+
|
|
43
|
+
interface AbilityRouteContext {
|
|
44
|
+
user: {
|
|
45
|
+
is_super_admin: boolean
|
|
46
|
+
abilities: string[]
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function useAbilityRouteContext(): AbilityRouteContext {
|
|
51
|
+
return useRouteContext({ from: getAdminRouteId() as never }) as AbilityRouteContext
|
|
52
|
+
}
|
|
43
53
|
|
|
44
54
|
/**
|
|
45
55
|
* `true` when the current admin holds the given ability (or is a
|
|
46
56
|
* super-admin). Cosmetic — see file-level docstring.
|
|
47
57
|
*/
|
|
48
58
|
export function useAbility(ability: string): boolean {
|
|
49
|
-
const { user } =
|
|
59
|
+
const { user } = useAbilityRouteContext()
|
|
50
60
|
if (user.is_super_admin) return true
|
|
51
61
|
return user.abilities.includes(ability)
|
|
52
62
|
}
|
|
@@ -62,7 +72,7 @@ export function useAbilities(): {
|
|
|
62
72
|
hasAny: (abilities: readonly string[]) => boolean
|
|
63
73
|
isSuperAdmin: boolean
|
|
64
74
|
} {
|
|
65
|
-
const { user } =
|
|
75
|
+
const { user } = useAbilityRouteContext()
|
|
66
76
|
const has = (ability: string): boolean => user.is_super_admin || user.abilities.includes(ability)
|
|
67
77
|
const hasAny = (abilities: readonly string[]): boolean =>
|
|
68
78
|
user.is_super_admin || abilities.some((key) => user.abilities.includes(key))
|
|
@@ -31,10 +31,12 @@ export function getAdminBylineClient(): BylineClient {
|
|
|
31
31
|
if (cachedClient) return cachedClient
|
|
32
32
|
cachedClient = createBylineClient({
|
|
33
33
|
config: getServerConfig(),
|
|
34
|
-
// Resolved
|
|
35
|
-
//
|
|
36
|
-
//
|
|
37
|
-
//
|
|
34
|
+
// Resolved from the current request's session cookies, memoized per
|
|
35
|
+
// request: every read in one request binds the same context instance
|
|
36
|
+
// (and requestId), which the ReadContext authority check requires.
|
|
37
|
+
// `getAdminRequestContext` runs the refresh dance on its own and throws
|
|
38
|
+
// `ERR_UNAUTHENTICATED` when no session is present — the client
|
|
39
|
+
// surfaces the throw verbatim.
|
|
38
40
|
requestContext: getAdminRequestContext,
|
|
39
41
|
})
|
|
40
42
|
return cachedClient
|
|
@@ -33,21 +33,35 @@
|
|
|
33
33
|
* process lifetime.
|
|
34
34
|
*
|
|
35
35
|
* Companion: `getAdminBylineClient` in `./byline-client.ts` for the
|
|
36
|
-
* admin webapp's authenticated reads (resolves
|
|
37
|
-
* from session cookies
|
|
36
|
+
* admin webapp's authenticated reads (resolves the `RequestContext`
|
|
37
|
+
* from session cookies, memoized per request).
|
|
38
38
|
*/
|
|
39
39
|
|
|
40
|
-
import { createRequestContext } from '@byline/auth'
|
|
40
|
+
import { createRequestContext, type RequestContext } from '@byline/auth'
|
|
41
41
|
import { type BylineClient, createBylineClient } from '@byline/client'
|
|
42
42
|
import { getServerConfig } from '@byline/core'
|
|
43
43
|
|
|
44
|
+
import { oncePerRequest } from '../auth/request-scope.js'
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Anonymous, published-only context — memoized per request so the
|
|
48
|
+
* context (and its requestId) is stable across every read in one
|
|
49
|
+
* request: reads sharing a ReadContext must bind a single request
|
|
50
|
+
* authority.
|
|
51
|
+
*/
|
|
52
|
+
export function resolvePublicRequestContext(): Promise<RequestContext> {
|
|
53
|
+
return oncePerRequest('byline:public-request-context', async () =>
|
|
54
|
+
createRequestContext({ readMode: 'published' })
|
|
55
|
+
)
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
let cachedClient: BylineClient | undefined
|
|
45
59
|
|
|
46
60
|
export function getPublicBylineClient(): BylineClient {
|
|
47
61
|
if (cachedClient) return cachedClient
|
|
48
62
|
cachedClient = createBylineClient({
|
|
49
63
|
config: getServerConfig(),
|
|
50
|
-
requestContext:
|
|
64
|
+
requestContext: resolvePublicRequestContext,
|
|
51
65
|
})
|
|
52
66
|
return cachedClient
|
|
53
67
|
}
|