@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.
Files changed (86) hide show
  1. package/dist/vite/index.js +81 -5
  2. package/package.json +19 -19
  3. package/src/api-client.ts +7 -1
  4. package/src/client.ts +2 -0
  5. package/src/components/address-form-dialog.tsx +75 -28
  6. package/src/components/app-shell.tsx +5 -4
  7. package/src/components/app-sidebar.tsx +2 -1
  8. package/src/components/bulk-action-bar.tsx +6 -5
  9. package/src/components/country-state-fields.tsx +3 -0
  10. package/src/components/currency-select.tsx +6 -4
  11. package/src/components/developer-inspector-drawer.tsx +322 -0
  12. package/src/components/file-upload-field.tsx +64 -37
  13. package/src/components/image-upload-field.tsx +4 -0
  14. package/src/components/import-wizard-dialog.tsx +14 -12
  15. package/src/components/invitation-acceptance.tsx +15 -11
  16. package/src/components/media-editor-dialog.tsx +131 -0
  17. package/src/components/media-picker-dialog.tsx +866 -0
  18. package/src/components/media-picker-sheet.tsx +13 -300
  19. package/src/components/mobile-top-bar.tsx +1 -1
  20. package/src/components/nav-main.tsx +13 -2
  21. package/src/components/page-header.tsx +57 -13
  22. package/src/components/page-tabs.tsx +1 -1
  23. package/src/components/product-stock-summary.tsx +1 -1
  24. package/src/components/read-row.tsx +38 -0
  25. package/src/components/resource-combobox.tsx +1 -1
  26. package/src/components/resource-image-field.tsx +150 -0
  27. package/src/components/resource-picker-sheet.tsx +2 -2
  28. package/src/components/resource-table.tsx +161 -40
  29. package/src/components/section-heading.tsx +2 -2
  30. package/src/components/settings-container.tsx +19 -0
  31. package/src/components/settings-fields.tsx +165 -0
  32. package/src/components/settings-section.tsx +46 -0
  33. package/src/components/settings-sidebar.tsx +35 -18
  34. package/src/components/sheet-top-bar.tsx +7 -8
  35. package/src/components/sidebar-search.tsx +11 -12
  36. package/src/components/sidebar-user.tsx +39 -44
  37. package/src/components/skip-link.tsx +1 -1
  38. package/src/components/store-switcher.tsx +1 -1
  39. package/src/components/table-toolbar.tsx +189 -323
  40. package/src/components/timezone-combobox.tsx +119 -0
  41. package/src/components/top-bar-breadcrumbs.tsx +1 -1
  42. package/src/hooks/use-stock-locations.ts +6 -2
  43. package/src/hooks/use-table-density.ts +50 -0
  44. package/src/index.ts +19 -2
  45. package/src/lib/curl.test.ts +18 -0
  46. package/src/lib/curl.ts +28 -0
  47. package/src/lib/developer-inspector.test.ts +169 -0
  48. package/src/lib/developer-inspector.ts +134 -0
  49. package/src/lib/docs.ts +3 -2
  50. package/src/lib/fulfillment-items.test.ts +51 -0
  51. package/src/lib/fulfillment-items.ts +53 -0
  52. package/src/lib/request-snippets.ts +41 -0
  53. package/src/lib/table-registry.ts +13 -6
  54. package/src/lib/timestamp-columns.tsx +1 -1
  55. package/src/locales/ar.json +120 -14
  56. package/src/locales/de.json +120 -14
  57. package/src/locales/en.json +120 -14
  58. package/src/locales/es.json +120 -14
  59. package/src/locales/fr.json +120 -14
  60. package/src/locales/ja.json +120 -14
  61. package/src/locales/pl.json +120 -14
  62. package/src/locales/vi.json +120 -14
  63. package/src/locales/zh-CN.json +120 -14
  64. package/src/pages/settings-index.tsx +79 -48
  65. package/src/pages/stock-locations.tsx +14 -6
  66. package/src/products/digital-asset-edit-sheet.tsx +1 -1
  67. package/src/products/digital-asset-provider-sheet.tsx +1 -1
  68. package/src/products/digital-assets-card.tsx +7 -7
  69. package/src/products/inventory-section.tsx +2 -2
  70. package/src/products/media-edit-sheet.tsx +136 -133
  71. package/src/products/product-form-cards.tsx +201 -70
  72. package/src/products/product-form-mapping.ts +1 -0
  73. package/src/products/product-schema.ts +8 -0
  74. package/src/products/product-types.ts +2 -0
  75. package/src/products/use-product-form-data.ts +30 -3
  76. package/src/products/variant-edit-sheet.tsx +3 -3
  77. package/src/products/variants-options-builder.tsx +13 -8
  78. package/src/products/variants-section.tsx +196 -4
  79. package/src/providers/auth-provider.tsx +2 -0
  80. package/src/providers/developer-inspector-provider.tsx +145 -0
  81. package/src/providers/json-link-resolver-provider.tsx +27 -0
  82. package/src/sellers/seller-schema.ts +107 -0
  83. package/src/tables/package-types.tsx +3 -3
  84. package/src/tables/stock-locations.tsx +15 -14
  85. package/src/virtual.d.ts +4 -0
  86. 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-sm text-muted-foreground transition-colors hover:text-foreground"
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: i18n.t('admin.messages.updated'),
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-picker-sheet'
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
+ })
@@ -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 other two destinations the account menu offers, kept here for the same
11
- * reason as the docs base: one edit if a domain moves.
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
  /**