@spree/dashboard-core 6.0.3 → 6.0.4
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/vite/index.js +81 -5
- package/package.json +19 -19
- package/src/api-client.ts +7 -1
- package/src/client.ts +2 -0
- package/src/components/address-form-dialog.tsx +75 -28
- package/src/components/app-shell.tsx +5 -4
- package/src/components/app-sidebar.tsx +2 -1
- package/src/components/bulk-action-bar.tsx +6 -5
- package/src/components/country-state-fields.tsx +3 -0
- package/src/components/currency-select.tsx +6 -4
- package/src/components/developer-inspector-drawer.tsx +322 -0
- package/src/components/file-upload-field.tsx +64 -37
- package/src/components/image-upload-field.tsx +4 -0
- package/src/components/import-wizard-dialog.tsx +14 -12
- package/src/components/invitation-acceptance.tsx +15 -11
- package/src/components/media-editor-dialog.tsx +131 -0
- package/src/components/media-picker-dialog.tsx +866 -0
- package/src/components/media-picker-sheet.tsx +13 -300
- package/src/components/mobile-top-bar.tsx +1 -1
- package/src/components/nav-main.tsx +13 -2
- package/src/components/page-header.tsx +57 -13
- package/src/components/page-tabs.tsx +1 -1
- package/src/components/product-stock-summary.tsx +1 -1
- package/src/components/read-row.tsx +38 -0
- package/src/components/resource-combobox.tsx +1 -1
- package/src/components/resource-image-field.tsx +150 -0
- package/src/components/resource-picker-sheet.tsx +2 -2
- package/src/components/resource-table.tsx +161 -40
- package/src/components/section-heading.tsx +2 -2
- package/src/components/settings-container.tsx +19 -0
- package/src/components/settings-fields.tsx +165 -0
- package/src/components/settings-section.tsx +46 -0
- package/src/components/settings-sidebar.tsx +35 -18
- package/src/components/sheet-top-bar.tsx +7 -8
- package/src/components/sidebar-search.tsx +11 -12
- package/src/components/sidebar-user.tsx +39 -44
- package/src/components/skip-link.tsx +1 -1
- package/src/components/store-switcher.tsx +1 -1
- package/src/components/table-toolbar.tsx +189 -323
- package/src/components/timezone-combobox.tsx +119 -0
- package/src/components/top-bar-breadcrumbs.tsx +1 -1
- package/src/hooks/use-stock-locations.ts +6 -2
- package/src/hooks/use-table-density.ts +50 -0
- package/src/index.ts +19 -2
- package/src/lib/curl.test.ts +18 -0
- package/src/lib/curl.ts +28 -0
- package/src/lib/developer-inspector.test.ts +169 -0
- package/src/lib/developer-inspector.ts +134 -0
- package/src/lib/docs.ts +3 -2
- package/src/lib/fulfillment-items.test.ts +51 -0
- package/src/lib/fulfillment-items.ts +53 -0
- package/src/lib/request-snippets.ts +41 -0
- package/src/lib/table-registry.ts +13 -6
- package/src/lib/timestamp-columns.tsx +1 -1
- package/src/locales/ar.json +120 -14
- package/src/locales/de.json +120 -14
- package/src/locales/en.json +120 -14
- package/src/locales/es.json +120 -14
- package/src/locales/fr.json +120 -14
- package/src/locales/ja.json +120 -14
- package/src/locales/pl.json +120 -14
- package/src/locales/vi.json +120 -14
- package/src/locales/zh-CN.json +120 -14
- package/src/pages/settings-index.tsx +79 -48
- package/src/pages/stock-locations.tsx +14 -6
- package/src/products/digital-asset-edit-sheet.tsx +1 -1
- package/src/products/digital-asset-provider-sheet.tsx +1 -1
- package/src/products/digital-assets-card.tsx +7 -7
- package/src/products/inventory-section.tsx +2 -2
- package/src/products/media-edit-sheet.tsx +136 -133
- package/src/products/product-form-cards.tsx +201 -70
- package/src/products/product-form-mapping.ts +1 -0
- package/src/products/product-schema.ts +8 -0
- package/src/products/product-types.ts +2 -0
- package/src/products/use-product-form-data.ts +30 -3
- package/src/products/variant-edit-sheet.tsx +3 -3
- package/src/products/variants-options-builder.tsx +13 -8
- package/src/products/variants-section.tsx +196 -4
- package/src/providers/auth-provider.tsx +2 -0
- package/src/providers/developer-inspector-provider.tsx +145 -0
- package/src/providers/json-link-resolver-provider.tsx +27 -0
- package/src/sellers/seller-schema.ts +107 -0
- package/src/tables/package-types.tsx +3 -3
- package/src/tables/stock-locations.tsx +15 -14
- package/src/virtual.d.ts +4 -0
- package/src/vite/index.ts +103 -5
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { TIME_ZONES } from '@spree/core'
|
|
2
|
+
import {
|
|
3
|
+
Combobox,
|
|
4
|
+
ComboboxButtonTrigger,
|
|
5
|
+
ComboboxContent,
|
|
6
|
+
ComboboxEmpty,
|
|
7
|
+
ComboboxItem,
|
|
8
|
+
ComboboxList,
|
|
9
|
+
ComboboxSearch,
|
|
10
|
+
ComboboxTriggerPlaceholder,
|
|
11
|
+
} from '@spree/dashboard-ui'
|
|
12
|
+
import { useMemo } from 'react'
|
|
13
|
+
import { useTranslation } from 'react-i18next'
|
|
14
|
+
|
|
15
|
+
type TimezoneOption = { zone: string; offset: string }
|
|
16
|
+
|
|
17
|
+
// Browsers list zones under their own, sometimes older, names (`Asia/Calcutta`
|
|
18
|
+
// for the `Asia/Kolkata` a store is saved with) and leave out `UTC`, the store
|
|
19
|
+
// default, so Spree's suggestions are always merged in.
|
|
20
|
+
const TIMEZONES: string[] = (() => {
|
|
21
|
+
let browserZones: string[] = []
|
|
22
|
+
try {
|
|
23
|
+
browserZones = Intl.supportedValuesOf('timeZone')
|
|
24
|
+
} catch {
|
|
25
|
+
// Older browsers cannot list zones; Spree's suggestions stand alone.
|
|
26
|
+
}
|
|
27
|
+
const zones = new Set<string>([...TIME_ZONES, ...browserZones])
|
|
28
|
+
zones.delete('UTC')
|
|
29
|
+
return ['UTC', ...[...zones].sort()]
|
|
30
|
+
})()
|
|
31
|
+
|
|
32
|
+
/** The zone's current offset from UTC, such as `GMT+5:30`, or '' when unknown. */
|
|
33
|
+
function utcOffset(zone: string) {
|
|
34
|
+
try {
|
|
35
|
+
const parts = new Intl.DateTimeFormat('en-US', {
|
|
36
|
+
timeZone: zone,
|
|
37
|
+
timeZoneName: 'shortOffset',
|
|
38
|
+
}).formatToParts(new Date())
|
|
39
|
+
return parts.find((part) => part.type === 'timeZoneName')?.value ?? ''
|
|
40
|
+
} catch {
|
|
41
|
+
return ''
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Typing "new york", "kolkata" or "GMT+5" finds the zone. */
|
|
46
|
+
function matches(option: TimezoneOption, query: string) {
|
|
47
|
+
const q = query.trim().toLowerCase()
|
|
48
|
+
if (!q) return true
|
|
49
|
+
return (
|
|
50
|
+
option.zone.toLowerCase().replaceAll('_', ' ').includes(q.replaceAll('_', ' ')) ||
|
|
51
|
+
option.offset.toLowerCase().includes(q)
|
|
52
|
+
)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Searchable timezone picker. Value is the IANA name the store saves. A saved
|
|
57
|
+
* zone missing from both lists stays selectable.
|
|
58
|
+
*/
|
|
59
|
+
export function TimezoneCombobox({
|
|
60
|
+
id,
|
|
61
|
+
value,
|
|
62
|
+
onValueChange,
|
|
63
|
+
onBlur,
|
|
64
|
+
invalid,
|
|
65
|
+
}: {
|
|
66
|
+
id?: string
|
|
67
|
+
value: string | null | undefined
|
|
68
|
+
onValueChange: (zone: string) => void
|
|
69
|
+
onBlur?: () => void
|
|
70
|
+
invalid?: boolean
|
|
71
|
+
}) {
|
|
72
|
+
const { t } = useTranslation()
|
|
73
|
+
const items = useMemo(
|
|
74
|
+
() =>
|
|
75
|
+
(!value || TIMEZONES.includes(value) ? TIMEZONES : [value, ...TIMEZONES]).map((zone) => ({
|
|
76
|
+
zone,
|
|
77
|
+
offset: utcOffset(zone),
|
|
78
|
+
})),
|
|
79
|
+
[value],
|
|
80
|
+
)
|
|
81
|
+
const selected = items.find((option) => option.zone === value) ?? null
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<Combobox
|
|
85
|
+
items={items}
|
|
86
|
+
value={selected}
|
|
87
|
+
onValueChange={(option: TimezoneOption | null) => option && onValueChange(option.zone)}
|
|
88
|
+
itemToStringLabel={(option: TimezoneOption | null) => option?.zone ?? ''}
|
|
89
|
+
itemToStringValue={(option: TimezoneOption | null) => option?.zone ?? ''}
|
|
90
|
+
filter={matches}
|
|
91
|
+
>
|
|
92
|
+
<ComboboxButtonTrigger id={id} onBlur={onBlur} aria-invalid={invalid || undefined}>
|
|
93
|
+
{selected ? (
|
|
94
|
+
<span className="truncate">{selected.zone}</span>
|
|
95
|
+
) : (
|
|
96
|
+
<ComboboxTriggerPlaceholder>
|
|
97
|
+
{t('admin.components.timezone_combobox.placeholder')}
|
|
98
|
+
</ComboboxTriggerPlaceholder>
|
|
99
|
+
)}
|
|
100
|
+
</ComboboxButtonTrigger>
|
|
101
|
+
<ComboboxContent>
|
|
102
|
+
<ComboboxSearch placeholder={t('admin.components.timezone_combobox.search_placeholder')} />
|
|
103
|
+
<ComboboxEmpty>{t('admin.components.timezone_combobox.empty')}</ComboboxEmpty>
|
|
104
|
+
<ComboboxList>
|
|
105
|
+
{(option: TimezoneOption) => (
|
|
106
|
+
<ComboboxItem key={option.zone} value={option}>
|
|
107
|
+
<span className="flex min-w-0 items-center gap-3">
|
|
108
|
+
<span className="truncate">{option.zone}</span>
|
|
109
|
+
{option.offset && (
|
|
110
|
+
<span className="text-xs text-muted-foreground">{option.offset}</span>
|
|
111
|
+
)}
|
|
112
|
+
</span>
|
|
113
|
+
</ComboboxItem>
|
|
114
|
+
)}
|
|
115
|
+
</ComboboxList>
|
|
116
|
+
</ComboboxContent>
|
|
117
|
+
</Combobox>
|
|
118
|
+
)
|
|
119
|
+
}
|
|
@@ -82,7 +82,7 @@ export function MobileBreadcrumbBar({
|
|
|
82
82
|
onClick={onOpenSettingsNav}
|
|
83
83
|
// 44px tall via the row; the negative inline margin keeps the
|
|
84
84
|
// text on the page gutter while the tap area overhangs it.
|
|
85
|
-
className="-ms-2 inline-flex h-11 items-center gap-1 rounded-lg px-2 text-
|
|
85
|
+
className="-ms-2 inline-flex h-11 items-center gap-1 rounded-lg px-2 text-base text-muted-foreground transition-colors hover:text-foreground"
|
|
86
86
|
>
|
|
87
87
|
<MenuIcon className="size-4" />
|
|
88
88
|
{crumb.label}
|
|
@@ -74,7 +74,11 @@ export function useUpdateStockLocation(id: string) {
|
|
|
74
74
|
* Update variant that takes the id per call, for lists where every row writes
|
|
75
75
|
* to a different location (the pickup toggles on a delivery profile).
|
|
76
76
|
*/
|
|
77
|
-
export function useUpdateStockLocationById(
|
|
77
|
+
export function useUpdateStockLocationById({
|
|
78
|
+
successMessage = i18n.t('admin.messages.updated'),
|
|
79
|
+
}: {
|
|
80
|
+
successMessage?: string
|
|
81
|
+
} = {}) {
|
|
78
82
|
return useResourceMutation<
|
|
79
83
|
PanelStockLocation,
|
|
80
84
|
Error,
|
|
@@ -82,7 +86,7 @@ export function useUpdateStockLocationById() {
|
|
|
82
86
|
>({
|
|
83
87
|
mutationFn: ({ id, params }) => resource().update(id, params),
|
|
84
88
|
invalidate: [['stock-locations']],
|
|
85
|
-
successMessage
|
|
89
|
+
successMessage,
|
|
86
90
|
errorMessage: i18n.t('admin.errors.failed_to_update'),
|
|
87
91
|
})
|
|
88
92
|
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { useSyncExternalStore } from 'react'
|
|
2
|
+
|
|
3
|
+
export type TableDensity = 'comfortable' | 'compact'
|
|
4
|
+
|
|
5
|
+
const STORAGE_KEY = 'spree-admin-table-density'
|
|
6
|
+
const listeners = new Set<() => void>()
|
|
7
|
+
|
|
8
|
+
function read(): TableDensity {
|
|
9
|
+
try {
|
|
10
|
+
return window.localStorage.getItem(STORAGE_KEY) === 'compact' ? 'compact' : 'comfortable'
|
|
11
|
+
} catch {
|
|
12
|
+
return 'comfortable'
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
// Tables read the density from <html>, so every table on every page follows
|
|
17
|
+
// one setting without each list threading a prop through.
|
|
18
|
+
function apply(density: TableDensity) {
|
|
19
|
+
if (typeof document === 'undefined') return
|
|
20
|
+
if (density === 'compact') document.documentElement.dataset.tableDensity = 'compact'
|
|
21
|
+
else delete document.documentElement.dataset.tableDensity
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
if (typeof window !== 'undefined') apply(read())
|
|
25
|
+
|
|
26
|
+
function setTableDensity(density: TableDensity) {
|
|
27
|
+
try {
|
|
28
|
+
if (density === 'compact') window.localStorage.setItem(STORAGE_KEY, density)
|
|
29
|
+
else window.localStorage.removeItem(STORAGE_KEY)
|
|
30
|
+
} catch {
|
|
31
|
+
// Storage blocked: the choice lasts until the page reloads.
|
|
32
|
+
}
|
|
33
|
+
apply(density)
|
|
34
|
+
for (const listener of listeners) listener()
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function subscribe(listener: () => void) {
|
|
38
|
+
listeners.add(listener)
|
|
39
|
+
return () => listeners.delete(listener)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** The signed-in browser's row density for every resource table. */
|
|
43
|
+
export function useTableDensity(): [TableDensity, (density: TableDensity) => void] {
|
|
44
|
+
const density = useSyncExternalStore(
|
|
45
|
+
subscribe,
|
|
46
|
+
() => (document.documentElement.dataset.tableDensity === 'compact' ? 'compact' : 'comfortable'),
|
|
47
|
+
() => 'comfortable' as const,
|
|
48
|
+
)
|
|
49
|
+
return [density, setTableDensity]
|
|
50
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -44,7 +44,9 @@ export * from './components/import-wizard-dialog'
|
|
|
44
44
|
export * from './components/invitation-acceptance'
|
|
45
45
|
export * from './components/locale-select'
|
|
46
46
|
export * from './components/market-combobox'
|
|
47
|
-
export * from './components/media-
|
|
47
|
+
export * from './components/media-editor-dialog'
|
|
48
|
+
export * from './components/media-picker-dialog'
|
|
49
|
+
export { MediaPickerSheet, type MediaPickerSheetProps } from './components/media-picker-sheet'
|
|
48
50
|
export * from './components/mobile-top-bar'
|
|
49
51
|
export * from './components/money'
|
|
50
52
|
export * from './components/nav-main'
|
|
@@ -52,13 +54,18 @@ export * from './components/page-header'
|
|
|
52
54
|
export * from './components/page-tabs'
|
|
53
55
|
export * from './components/preferences-form'
|
|
54
56
|
export * from './components/product-stock-summary'
|
|
57
|
+
export * from './components/read-row'
|
|
55
58
|
export * from './components/record-metadata'
|
|
56
59
|
export * from './components/resource-combobox'
|
|
60
|
+
export * from './components/resource-image-field'
|
|
57
61
|
export * from './components/resource-multi-autocomplete'
|
|
58
62
|
export * from './components/resource-picker-sheet'
|
|
59
63
|
export * from './components/resource-table'
|
|
60
64
|
export * from './components/route-announcer'
|
|
61
65
|
export * from './components/section-heading'
|
|
66
|
+
export * from './components/settings-container'
|
|
67
|
+
export * from './components/settings-fields'
|
|
68
|
+
export * from './components/settings-section'
|
|
62
69
|
export * from './components/settings-sidebar'
|
|
63
70
|
export * from './components/sheet-top-bar'
|
|
64
71
|
export * from './components/sidebar-search'
|
|
@@ -69,6 +76,7 @@ export * from './components/store-date-picker'
|
|
|
69
76
|
export * from './components/store-switcher'
|
|
70
77
|
export * from './components/table-toolbar'
|
|
71
78
|
export * from './components/tag-combobox'
|
|
79
|
+
export * from './components/timezone-combobox'
|
|
72
80
|
export * from './components/top-bar-breadcrumbs'
|
|
73
81
|
// ---------------------------------------------------------------------------
|
|
74
82
|
// Infra hooks
|
|
@@ -89,17 +97,19 @@ export * from './hooks/use-package-types'
|
|
|
89
97
|
export * from './hooks/use-resource-mutation'
|
|
90
98
|
export * from './hooks/use-stock-locations'
|
|
91
99
|
export * from './hooks/use-switch-admin-locale'
|
|
100
|
+
export * from './hooks/use-table-density'
|
|
92
101
|
export * from './hooks/use-visual-viewport-offset'
|
|
93
102
|
export * from './lib/cancel-order-form'
|
|
94
103
|
export * from './lib/create-action-registry'
|
|
95
|
-
export * from './lib/currency-amounts'
|
|
96
104
|
// ---------------------------------------------------------------------------
|
|
97
105
|
// Registries — pluggable extension points (nav, route, slot, table,
|
|
98
106
|
// settings-nav, form fields, custom field components)
|
|
99
107
|
// ---------------------------------------------------------------------------
|
|
108
|
+
export * from './lib/currency-amounts'
|
|
100
109
|
export * from './lib/custom-field-components'
|
|
101
110
|
export * from './lib/date-presets'
|
|
102
111
|
export * from './lib/delivery-form'
|
|
112
|
+
export { type DeveloperInspectorEntry, isFailedEntry } from './lib/developer-inspector'
|
|
103
113
|
// ---------------------------------------------------------------------------
|
|
104
114
|
// Helpers
|
|
105
115
|
// ---------------------------------------------------------------------------
|
|
@@ -155,9 +165,16 @@ export * from './products/video-url'
|
|
|
155
165
|
// Providers — must be mounted by the consuming app shell
|
|
156
166
|
// ---------------------------------------------------------------------------
|
|
157
167
|
export * from './providers/auth-provider'
|
|
168
|
+
export {
|
|
169
|
+
DeveloperInspectorProvider,
|
|
170
|
+
useDeveloperInspectorEntries,
|
|
171
|
+
useOptionalDeveloperInspector,
|
|
172
|
+
} from './providers/developer-inspector-provider'
|
|
173
|
+
export * from './providers/json-link-resolver-provider'
|
|
158
174
|
export * from './providers/permission-provider'
|
|
159
175
|
export * from './providers/sticky-header-provider'
|
|
160
176
|
export * from './providers/store-provider'
|
|
161
177
|
export * from './providers/tenant-provider'
|
|
162
178
|
export * from './schemas/package-type'
|
|
163
179
|
export * from './schemas/stock-location'
|
|
180
|
+
export * from './sellers/seller-schema'
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { buildCurlCommand } from './curl'
|
|
3
|
+
|
|
4
|
+
describe('buildCurlCommand', () => {
|
|
5
|
+
it('uses the token placeholder and extra headers', () => {
|
|
6
|
+
expect(
|
|
7
|
+
buildCurlCommand('https://api.example.com/api/v3/seller/orders/or_1', {
|
|
8
|
+
'X-Spree-Seller-Id': 'seller_1',
|
|
9
|
+
}),
|
|
10
|
+
).toBe(
|
|
11
|
+
[
|
|
12
|
+
"curl 'https://api.example.com/api/v3/seller/orders/or_1'",
|
|
13
|
+
' -H "Authorization: Bearer $SPREE_API_TOKEN"',
|
|
14
|
+
" -H 'X-Spree-Seller-Id: seller_1'",
|
|
15
|
+
].join(' \\\n'),
|
|
16
|
+
)
|
|
17
|
+
})
|
|
18
|
+
})
|
package/src/lib/curl.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const API_BASE_URL = import.meta.env.VITE_SPREE_API_URL || ''
|
|
2
|
+
|
|
3
|
+
/** Stands in for the bearer token so a copied command never carries the real one. */
|
|
4
|
+
export const CURL_TOKEN_PLACEHOLDER = '$SPREE_API_TOKEN'
|
|
5
|
+
|
|
6
|
+
function shellQuote(value: string): string {
|
|
7
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* A `curl` command for a request to `path`, resolved against the API base the
|
|
12
|
+
* client uses (the page's own origin when the API is proxied through it).
|
|
13
|
+
*/
|
|
14
|
+
export function buildCurlCommand(
|
|
15
|
+
path: string,
|
|
16
|
+
headers: Record<string, string> = {},
|
|
17
|
+
{ method = 'GET', body }: { method?: string; body?: unknown } = {},
|
|
18
|
+
): string {
|
|
19
|
+
const base = API_BASE_URL || (typeof window === 'undefined' ? '' : window.location.origin)
|
|
20
|
+
const url = /^https?:\/\//.test(path) ? path : `${base}${path}`
|
|
21
|
+
const lines = [
|
|
22
|
+
`curl ${method === 'GET' ? '' : `-X ${method} `}${shellQuote(url)}`,
|
|
23
|
+
` -H "Authorization: Bearer ${CURL_TOKEN_PLACEHOLDER}"`,
|
|
24
|
+
...Object.entries(headers).map(([name, value]) => ` -H ${shellQuote(`${name}: ${value}`)}`),
|
|
25
|
+
...(body === undefined ? [] : [` -d ${shellQuote(JSON.stringify(body, null, 2))}`]),
|
|
26
|
+
]
|
|
27
|
+
return lines.join(' \\\n')
|
|
28
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import type { RequestCompleteEvent } from '@spree/admin-sdk'
|
|
2
|
+
import { afterEach, describe, expect, it } from 'vitest'
|
|
3
|
+
import {
|
|
4
|
+
clearDeveloperInspector,
|
|
5
|
+
DEVELOPER_INSPECTOR_LIMIT,
|
|
6
|
+
getDeveloperInspectorEntries,
|
|
7
|
+
recordRequest,
|
|
8
|
+
redactBody,
|
|
9
|
+
} from './developer-inspector'
|
|
10
|
+
import { entryToCurl, entryToSdkCall } from './request-snippets'
|
|
11
|
+
|
|
12
|
+
function event(overrides: Partial<RequestCompleteEvent> = {}): RequestCompleteEvent {
|
|
13
|
+
return {
|
|
14
|
+
method: 'GET',
|
|
15
|
+
path: '/api/v3/admin/products',
|
|
16
|
+
url: 'http://localhost/api/v3/admin/products',
|
|
17
|
+
status: 200,
|
|
18
|
+
durationMs: 12.4,
|
|
19
|
+
requestId: 'req-1',
|
|
20
|
+
requestHeaders: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
Authorization: 'Bearer real-jwt',
|
|
23
|
+
'X-Spree-Store-Id': 'store_1',
|
|
24
|
+
'Idempotency-Key': 'spree-sdk-retry-1',
|
|
25
|
+
},
|
|
26
|
+
requestBody: undefined,
|
|
27
|
+
responseBody: { data: [] },
|
|
28
|
+
...overrides,
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
afterEach(() => clearDeveloperInspector())
|
|
33
|
+
|
|
34
|
+
describe('recordRequest', () => {
|
|
35
|
+
it('records newest first, with the token and secrets masked', () => {
|
|
36
|
+
recordRequest(event())
|
|
37
|
+
recordRequest(
|
|
38
|
+
event({
|
|
39
|
+
method: 'POST',
|
|
40
|
+
path: '/api/v3/admin/auth/login',
|
|
41
|
+
requestBody: { email: 'a@b.c', password: 'hunter2' },
|
|
42
|
+
responseBody: { token: 'jwt', user: { api_keys: [{ Secret_Key: 'sk_1', name: 'k' }] } },
|
|
43
|
+
}),
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
const [login, list] = getDeveloperInspectorEntries()
|
|
47
|
+
expect(list.path).toBe('/api/v3/admin/products')
|
|
48
|
+
expect(list.durationMs).toBe(12)
|
|
49
|
+
expect(login.requestHeaders.Authorization).toBe('[FILTERED]')
|
|
50
|
+
expect(login.requestBody).toEqual({ email: 'a@b.c', password: '[FILTERED]' })
|
|
51
|
+
expect(login.responseBody).toEqual({
|
|
52
|
+
token: '[FILTERED]',
|
|
53
|
+
user: { api_keys: [{ Secret_Key: '[FILTERED]', name: 'k' }] },
|
|
54
|
+
})
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('masks a password reset token carried in the path', () => {
|
|
58
|
+
recordRequest(
|
|
59
|
+
event({
|
|
60
|
+
method: 'PATCH',
|
|
61
|
+
path: '/api/v3/admin/auth/password_resets/reset-token-123',
|
|
62
|
+
url: 'http://localhost/api/v3/admin/auth/password_resets/reset-token-123',
|
|
63
|
+
status: 422,
|
|
64
|
+
}),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
const [reset] = getDeveloperInspectorEntries()
|
|
68
|
+
expect(reset.path).toBe('/api/v3/admin/auth/password_resets/[FILTERED]')
|
|
69
|
+
expect(reset.url).toBe('http://localhost/api/v3/admin/auth/password_resets/[FILTERED]')
|
|
70
|
+
expect(JSON.stringify(reset)).not.toContain('reset-token-123')
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it('masks secret query parameters in the query and the URL', () => {
|
|
74
|
+
recordRequest(
|
|
75
|
+
event({
|
|
76
|
+
query: { page: 2, token: 'tok_1' },
|
|
77
|
+
url: 'http://localhost/api/v3/admin/products?page=2&token=tok_1',
|
|
78
|
+
}),
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
const [entry] = getDeveloperInspectorEntries()
|
|
82
|
+
expect(entry.query).toEqual({ page: 2, token: '[FILTERED]' })
|
|
83
|
+
expect(entry.url).toBe('http://localhost/api/v3/admin/products?page=2&token=%5BFILTERED%5D')
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('keeps the API error code of a failed request', () => {
|
|
87
|
+
recordRequest(
|
|
88
|
+
event({
|
|
89
|
+
status: 404,
|
|
90
|
+
error: Object.assign(new Error('Not found'), { code: 'record_not_found' }),
|
|
91
|
+
}),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
expect(getDeveloperInspectorEntries()[0]).toMatchObject({
|
|
95
|
+
errorCode: 'record_not_found',
|
|
96
|
+
errorMessage: 'Not found',
|
|
97
|
+
})
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it(`keeps only the last ${DEVELOPER_INSPECTOR_LIMIT} requests`, () => {
|
|
101
|
+
for (let i = 0; i < DEVELOPER_INSPECTOR_LIMIT + 5; i++) recordRequest(event())
|
|
102
|
+
expect(getDeveloperInspectorEntries()).toHaveLength(DEVELOPER_INSPECTOR_LIMIT)
|
|
103
|
+
})
|
|
104
|
+
|
|
105
|
+
it('forgets everything when cleared', () => {
|
|
106
|
+
recordRequest(event())
|
|
107
|
+
clearDeveloperInspector()
|
|
108
|
+
expect(getDeveloperInspectorEntries()).toEqual([])
|
|
109
|
+
})
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
describe('redactBody', () => {
|
|
113
|
+
it('leaves scalars and unrelated fields alone', () => {
|
|
114
|
+
expect(redactBody('x')).toBe('x')
|
|
115
|
+
expect(redactBody([{ name: 'a', token_count: null }])).toEqual([
|
|
116
|
+
{ name: 'a', token_count: null },
|
|
117
|
+
])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("hides what Rails' filter_parameters hides", () => {
|
|
121
|
+
expect(
|
|
122
|
+
redactBody({
|
|
123
|
+
key: 'color',
|
|
124
|
+
preferences: { api_key: 'sk_live_1', private_key: 'pk', webhook_secret: 'whsec' },
|
|
125
|
+
source: { cvv: '123', number: '4242' },
|
|
126
|
+
}),
|
|
127
|
+
).toEqual({
|
|
128
|
+
key: 'color',
|
|
129
|
+
preferences: {
|
|
130
|
+
api_key: '[FILTERED]',
|
|
131
|
+
private_key: '[FILTERED]',
|
|
132
|
+
webhook_secret: '[FILTERED]',
|
|
133
|
+
},
|
|
134
|
+
source: { cvv: '[FILTERED]', number: '4242' },
|
|
135
|
+
})
|
|
136
|
+
})
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
describe('request snippets', () => {
|
|
140
|
+
it('builds a curl command with a placeholder token and no idempotency key', () => {
|
|
141
|
+
recordRequest(
|
|
142
|
+
event({
|
|
143
|
+
method: 'PATCH',
|
|
144
|
+
path: '/api/v3/admin/products/prod_1',
|
|
145
|
+
query: { expand: ['variants'] },
|
|
146
|
+
url: 'http://localhost/api/v3/admin/products/prod_1?expand=variants',
|
|
147
|
+
requestBody: { name: "Bob's shirt" },
|
|
148
|
+
}),
|
|
149
|
+
)
|
|
150
|
+
const curl = entryToCurl(getDeveloperInspectorEntries()[0])
|
|
151
|
+
|
|
152
|
+
expect(curl).toContain(
|
|
153
|
+
"curl -X PATCH 'http://localhost/api/v3/admin/products/prod_1?expand=variants'",
|
|
154
|
+
)
|
|
155
|
+
expect(curl).toContain('Authorization: Bearer $SPREE_API_TOKEN')
|
|
156
|
+
expect(curl).toContain("'X-Spree-Store-Id: store_1'")
|
|
157
|
+
expect(curl).not.toContain('real-jwt')
|
|
158
|
+
expect(curl).not.toContain('Idempotency-Key')
|
|
159
|
+
expect(curl).toContain(`"Bob'\\''s shirt"`)
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it('builds an SDK call relative to the Admin API', () => {
|
|
163
|
+
recordRequest(event({ query: { page: 2 } }))
|
|
164
|
+
|
|
165
|
+
expect(entryToSdkCall(getDeveloperInspectorEntries()[0])).toBe(
|
|
166
|
+
'await adminClient.request("GET", "/products", {\n "params": {\n "page": 2\n }\n})',
|
|
167
|
+
)
|
|
168
|
+
})
|
|
169
|
+
})
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
import type { RequestCompleteEvent } from '@spree/admin-sdk'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The developer inspector's request log: the admin client's recent requests,
|
|
5
|
+
* kept in memory for this tab only. Nothing is persisted or sent anywhere.
|
|
6
|
+
* Always recording, so the request behind a bug is already there when an
|
|
7
|
+
* admin opens the drawer; the cap keeps the memory a tab holds small.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const DEVELOPER_INSPECTOR_LIMIT = 50
|
|
11
|
+
|
|
12
|
+
const FILTERED = '[FILTERED]'
|
|
13
|
+
/** Credential headers: masked in the log, never copied into a curl command. */
|
|
14
|
+
export const SENSITIVE_HEADERS = new Set([
|
|
15
|
+
'authorization',
|
|
16
|
+
'x-spree-api-key',
|
|
17
|
+
'x-spree-token',
|
|
18
|
+
'cookie',
|
|
19
|
+
])
|
|
20
|
+
// Rails' own filter_parameters list, so whatever the server's logs hide is
|
|
21
|
+
// hidden here too.
|
|
22
|
+
const SENSITIVE_FIELD = /passw|token|secret|_key$|crypt|salt|certificate|otp|ssn|cvv|cvc/i
|
|
23
|
+
|
|
24
|
+
export interface DeveloperInspectorEntry {
|
|
25
|
+
id: number
|
|
26
|
+
method: string
|
|
27
|
+
path: string
|
|
28
|
+
query?: RequestCompleteEvent['query']
|
|
29
|
+
url: string
|
|
30
|
+
/** `null` when no response arrived (network error). */
|
|
31
|
+
status: number | null
|
|
32
|
+
durationMs: number
|
|
33
|
+
requestId: string | null
|
|
34
|
+
requestHeaders: Record<string, string>
|
|
35
|
+
requestBody: unknown
|
|
36
|
+
responseBody: unknown
|
|
37
|
+
/** The API error code (`record_not_found`), or the network error's name. */
|
|
38
|
+
errorCode?: string
|
|
39
|
+
errorMessage?: string
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function isFailedEntry(entry: DeveloperInspectorEntry): boolean {
|
|
43
|
+
return entry.status === null || entry.status >= 400
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Deep-copies a body, replacing every `password`, `token` or `secret` field. */
|
|
47
|
+
export function redactBody(value: unknown): unknown {
|
|
48
|
+
if (typeof FormData !== 'undefined' && value instanceof FormData) return '[FormData]'
|
|
49
|
+
if (Array.isArray(value)) return value.map(redactBody)
|
|
50
|
+
if (value && typeof value === 'object') {
|
|
51
|
+
return Object.fromEntries(
|
|
52
|
+
Object.entries(value).map(([key, v]) => [
|
|
53
|
+
key,
|
|
54
|
+
SENSITIVE_FIELD.test(key) && v != null ? FILTERED : redactBody(v),
|
|
55
|
+
]),
|
|
56
|
+
)
|
|
57
|
+
}
|
|
58
|
+
return value
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// A password reset carries its token in the path, so a failed attempt would
|
|
62
|
+
// otherwise show it in the drawer and in a bug report's failed requests.
|
|
63
|
+
const TOKEN_IN_PATH = /(\/auth\/password_resets\/)[^/?#]+/
|
|
64
|
+
|
|
65
|
+
export function redactPath(path: string): string {
|
|
66
|
+
return path.replace(TOKEN_IN_PATH, `$1${FILTERED}`)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function redactUrl(url: string): string {
|
|
70
|
+
const path = redactPath(url)
|
|
71
|
+
const start = path.indexOf('?')
|
|
72
|
+
if (start === -1) return path
|
|
73
|
+
const params = new URLSearchParams(path.slice(start + 1))
|
|
74
|
+
const sensitive = [...new Set(params.keys())].filter((key) => SENSITIVE_FIELD.test(key))
|
|
75
|
+
if (sensitive.length === 0) return path
|
|
76
|
+
for (const key of sensitive) params.set(key, FILTERED)
|
|
77
|
+
return `${path.slice(0, start)}?${params}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function redactHeaders(headers: Record<string, string>): Record<string, string> {
|
|
81
|
+
return Object.fromEntries(
|
|
82
|
+
Object.entries(headers).map(([name, value]) => [
|
|
83
|
+
name,
|
|
84
|
+
SENSITIVE_HEADERS.has(name.toLowerCase()) ? FILTERED : value,
|
|
85
|
+
]),
|
|
86
|
+
)
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
let entries: DeveloperInspectorEntry[] = []
|
|
90
|
+
let nextId = 1
|
|
91
|
+
const listeners = new Set<() => void>()
|
|
92
|
+
|
|
93
|
+
function emit() {
|
|
94
|
+
for (const listener of listeners) listener()
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function subscribeDeveloperInspector(listener: () => void): () => void {
|
|
98
|
+
listeners.add(listener)
|
|
99
|
+
return () => listeners.delete(listener)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
export function getDeveloperInspectorEntries(): DeveloperInspectorEntry[] {
|
|
103
|
+
return entries
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export function clearDeveloperInspector(): void {
|
|
107
|
+
entries = []
|
|
108
|
+
emit()
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The admin client's `onRequestComplete`. */
|
|
112
|
+
export function recordRequest(event: RequestCompleteEvent): void {
|
|
113
|
+
const error = event.error as { code?: unknown; name?: unknown; message?: unknown } | undefined
|
|
114
|
+
const entry: DeveloperInspectorEntry = {
|
|
115
|
+
id: nextId++,
|
|
116
|
+
method: event.method,
|
|
117
|
+
path: redactUrl(event.path),
|
|
118
|
+
query: redactBody(event.query) as RequestCompleteEvent['query'],
|
|
119
|
+
url: redactUrl(event.url),
|
|
120
|
+
status: event.status,
|
|
121
|
+
durationMs: Math.round(event.durationMs),
|
|
122
|
+
requestId: event.requestId,
|
|
123
|
+
requestHeaders: redactHeaders(event.requestHeaders),
|
|
124
|
+
requestBody: redactBody(event.requestBody),
|
|
125
|
+
responseBody: redactBody(event.responseBody),
|
|
126
|
+
}
|
|
127
|
+
if (error) {
|
|
128
|
+
entry.errorCode = String(error.code ?? error.name ?? 'error')
|
|
129
|
+
if (typeof error.message === 'string') entry.errorMessage = error.message
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
entries = [entry, ...entries].slice(0, DEVELOPER_INSPECTOR_LIMIT)
|
|
133
|
+
emit()
|
|
134
|
+
}
|
package/src/lib/docs.ts
CHANGED
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
export const DOCS_BASE_URL = 'https://spreecommerce.org/docs'
|
|
8
8
|
|
|
9
9
|
/**
|
|
10
|
-
* The
|
|
11
|
-
*
|
|
10
|
+
* The community link the account menu offers, kept here for the same reason
|
|
11
|
+
* as the docs base: one edit if a domain moves.
|
|
12
12
|
*/
|
|
13
13
|
export const COMMUNITY_URL = 'https://discord.spreecommerce.org'
|
|
14
|
+
/** No longer linked from the account menu; kept for plugins that import it. */
|
|
14
15
|
export const CONTACT_URL = 'https://spreecommerce.org/contact'
|
|
15
16
|
|
|
16
17
|
/**
|