@spree/dashboard-core 0.1.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/LICENSE +21 -0
- package/README.md +418 -0
- package/package.json +84 -0
- package/src/client.ts +25 -0
- package/src/components/address-form-dialog.tsx +376 -0
- package/src/components/app-sidebar.tsx +70 -0
- package/src/components/bulk-action-bar.tsx +504 -0
- package/src/components/can.tsx +30 -0
- package/src/components/country-combobox.tsx +238 -0
- package/src/components/country-state-fields.tsx +86 -0
- package/src/components/currency-select.tsx +181 -0
- package/src/components/export-button.tsx +156 -0
- package/src/components/file-upload-field.tsx +265 -0
- package/src/components/image-upload-field.tsx +53 -0
- package/src/components/import-button.tsx +202 -0
- package/src/components/locale-select.tsx +268 -0
- package/src/components/market-combobox.tsx +89 -0
- package/src/components/nav-main.tsx +186 -0
- package/src/components/page-header.tsx +286 -0
- package/src/components/page-tabs.tsx +78 -0
- package/src/components/preferences-form.tsx +404 -0
- package/src/components/resource-combobox.tsx +126 -0
- package/src/components/resource-multi-autocomplete.tsx +176 -0
- package/src/components/resource-picker-sheet.tsx +235 -0
- package/src/components/resource-table.tsx +652 -0
- package/src/components/settings-sidebar.tsx +139 -0
- package/src/components/slot.tsx +50 -0
- package/src/components/store-date-picker.tsx +18 -0
- package/src/components/store-switcher.tsx +70 -0
- package/src/components/table-toolbar.tsx +818 -0
- package/src/components/tag-combobox.tsx +99 -0
- package/src/components/top-bar.tsx +210 -0
- package/src/hooks/use-auth.ts +10 -0
- package/src/hooks/use-command-palette.tsx +34 -0
- package/src/hooks/use-countries.ts +15 -0
- package/src/hooks/use-custom-fields.ts +142 -0
- package/src/hooks/use-direct-upload.ts +104 -0
- package/src/hooks/use-display-name.ts +42 -0
- package/src/hooks/use-export.ts +75 -0
- package/src/hooks/use-global-search.ts +79 -0
- package/src/hooks/use-host-form.ts +34 -0
- package/src/hooks/use-import.ts +62 -0
- package/src/hooks/use-resource-mutation.ts +99 -0
- package/src/hooks/use-switch-admin-locale.ts +55 -0
- package/src/index.ts +97 -0
- package/src/lib/custom-field-components.ts +56 -0
- package/src/lib/download.ts +49 -0
- package/src/lib/filters-to-ransack.ts +47 -0
- package/src/lib/form-errors.ts +103 -0
- package/src/lib/form-fields-registry.ts +102 -0
- package/src/lib/form-mappers.ts +30 -0
- package/src/lib/formatters.ts +19 -0
- package/src/lib/i18n.ts +210 -0
- package/src/lib/nav-registry.ts +224 -0
- package/src/lib/permissions.ts +55 -0
- package/src/lib/query-client.ts +11 -0
- package/src/lib/query-keys.ts +69 -0
- package/src/lib/route-registry.ts +158 -0
- package/src/lib/search-registry.ts +129 -0
- package/src/lib/settings-nav-registry.ts +158 -0
- package/src/lib/slot-registry.ts +116 -0
- package/src/lib/table-registry.ts +274 -0
- package/src/lib/timestamp-columns.tsx +58 -0
- package/src/locales/ar.json +629 -0
- package/src/locales/de.json +629 -0
- package/src/locales/en.json +629 -0
- package/src/locales/fr.json +629 -0
- package/src/locales/pl.json +629 -0
- package/src/locales/zh-CN.json +629 -0
- package/src/plugin.ts +222 -0
- package/src/providers/auth-provider.tsx +188 -0
- package/src/providers/permission-provider.tsx +118 -0
- package/src/providers/store-provider.tsx +111 -0
- package/src/virtual.d.ts +5 -0
- package/src/vite/discover.ts +170 -0
- package/src/vite/index.ts +270 -0
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// `@spree/dashboard-core/plugin` — the single import plugin authors use.
|
|
2
|
+
//
|
|
3
|
+
// A plugin's entry module calls `defineDashboardPlugin({...})` at import time;
|
|
4
|
+
// the dashboard app imports plugin entries in its bootstrap before the first
|
|
5
|
+
// render. Each registry uses `useSyncExternalStore`, so late registration
|
|
6
|
+
// (after the app has mounted) still re-renders consumers.
|
|
7
|
+
//
|
|
8
|
+
// Example:
|
|
9
|
+
//
|
|
10
|
+
// import { defineDashboardPlugin } from '@spree/dashboard-core/plugin'
|
|
11
|
+
// import { Card } from '@spree/dashboard-ui'
|
|
12
|
+
//
|
|
13
|
+
// function WishlistCount({ product }: { product: { wishlist_count: number } }) {
|
|
14
|
+
// return <Card>Wishlists: {product.wishlist_count}</Card>
|
|
15
|
+
// }
|
|
16
|
+
//
|
|
17
|
+
// defineDashboardPlugin({
|
|
18
|
+
// nav: [
|
|
19
|
+
// { key: 'wishlists', label: 'Wishlists', path: '/wishlists', position: 50 },
|
|
20
|
+
// ],
|
|
21
|
+
// slots: {
|
|
22
|
+
// 'product.form_sidebar': [
|
|
23
|
+
// { id: 'wishlist-count', component: WishlistCount, position: 50 },
|
|
24
|
+
// ],
|
|
25
|
+
// },
|
|
26
|
+
// tables: {
|
|
27
|
+
// products: { add: [{ key: 'wishlist_count', label: 'Wishlists', sortable: true }] },
|
|
28
|
+
// },
|
|
29
|
+
// settingsNav: [
|
|
30
|
+
// { key: 'wishlist-settings', label: 'Wishlists', path: '/wishlists', group: 'integrations' },
|
|
31
|
+
// ],
|
|
32
|
+
// })
|
|
33
|
+
|
|
34
|
+
import type { CustomFieldComponent } from './lib/custom-field-components'
|
|
35
|
+
import { customFieldComponents } from './lib/custom-field-components'
|
|
36
|
+
import type { FormFieldRegistration } from './lib/form-fields-registry'
|
|
37
|
+
import { formFields } from './lib/form-fields-registry'
|
|
38
|
+
import type { NavEntry } from './lib/nav-registry'
|
|
39
|
+
import { nav } from './lib/nav-registry'
|
|
40
|
+
import type { RouteEntry } from './lib/route-registry'
|
|
41
|
+
import { pluginRoutes } from './lib/route-registry'
|
|
42
|
+
import type { SettingsNavEntry, SettingsNavGroup } from './lib/settings-nav-registry'
|
|
43
|
+
import { settingsNav } from './lib/settings-nav-registry'
|
|
44
|
+
import type { SlotEntry } from './lib/slot-registry'
|
|
45
|
+
import { registerSlot } from './lib/slot-registry'
|
|
46
|
+
import type { ColumnDef } from './lib/table-registry'
|
|
47
|
+
import { tables } from './lib/table-registry'
|
|
48
|
+
|
|
49
|
+
export interface TableMutations {
|
|
50
|
+
/** Columns to append to the table. Keys must not already exist. */
|
|
51
|
+
add?: ColumnDef[]
|
|
52
|
+
/** Column keys to remove. No-op when the column is already gone. */
|
|
53
|
+
remove?: string[]
|
|
54
|
+
/** Column patches keyed by column key — `{ totals: { label: 'Net total' } }`. */
|
|
55
|
+
update?: Record<string, Partial<ColumnDef>>
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface NavMutations {
|
|
59
|
+
/** Sidebar entries to add. Each must have a unique `key`. */
|
|
60
|
+
add?: NavEntry[]
|
|
61
|
+
/** Entry keys to remove — built-in or plugin. No-op when already gone. */
|
|
62
|
+
remove?: string[]
|
|
63
|
+
/**
|
|
64
|
+
* Patches keyed by entry key — reorder via `position`, rename via `label`,
|
|
65
|
+
* gate via `subject`. Throws when the key isn't registered, so patch only
|
|
66
|
+
* entries you know exist (built-ins register before any plugin runs).
|
|
67
|
+
*/
|
|
68
|
+
update?: Record<string, Partial<Omit<NavEntry, 'key'>>>
|
|
69
|
+
/**
|
|
70
|
+
* Children to append under existing top-level entries, keyed by parent key
|
|
71
|
+
* — e.g. `{ products: [{ key: 'products.brands', … }] }` nests an item in
|
|
72
|
+
* the built-in Products menu. Preserves the parent's existing children.
|
|
73
|
+
* Throws when the parent is missing or a child key already exists there.
|
|
74
|
+
*/
|
|
75
|
+
addChildren?: Record<string, NavEntry[]>
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export interface SettingsNavMutations {
|
|
79
|
+
/** Settings sub-shell entries to add. */
|
|
80
|
+
add?: SettingsNavEntry[]
|
|
81
|
+
/** Entry keys to remove. No-op when already gone. */
|
|
82
|
+
remove?: string[]
|
|
83
|
+
/** Patches keyed by entry key. Throws when the key isn't registered. */
|
|
84
|
+
update?: Record<string, Partial<Omit<SettingsNavEntry, 'key'>>>
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface DashboardPluginConfig {
|
|
88
|
+
/**
|
|
89
|
+
* Sidebar entries. The array form adds entries; the object form also
|
|
90
|
+
* removes or patches existing ones (built-ins included):
|
|
91
|
+
*
|
|
92
|
+
* nav: {
|
|
93
|
+
* add: [{ key: 'reviews', label: 'Reviews', path: '/reviews' }],
|
|
94
|
+
* remove: ['gift-cards'],
|
|
95
|
+
* update: { products: { position: 10 } },
|
|
96
|
+
* }
|
|
97
|
+
*/
|
|
98
|
+
nav?: NavEntry[] | NavMutations
|
|
99
|
+
/** Settings sub-shell groups (defined before any entry that uses the group key). */
|
|
100
|
+
settingsNavGroups?: SettingsNavGroup[]
|
|
101
|
+
/** Settings sub-shell entries — array adds; object form also removes/patches. */
|
|
102
|
+
settingsNav?: SettingsNavEntry[] | SettingsNavMutations
|
|
103
|
+
/** Slot extensions keyed by slot name. Each entry must have a unique `id`. */
|
|
104
|
+
slots?: Record<string, SlotEntry[]>
|
|
105
|
+
/** Table mutations keyed by table key (see `defineTable`). */
|
|
106
|
+
tables?: Record<string, TableMutations>
|
|
107
|
+
/**
|
|
108
|
+
* Custom routes mounted under `/$storeId/`. Each entry's `path` is relative
|
|
109
|
+
* (e.g. `/brands`, `/brands/$brandId`). The dashboard's catch-all route at
|
|
110
|
+
* `/$storeId/*` dispatches based on the splat — your `component` renders
|
|
111
|
+
* exactly like any first-party route, including TanStack Router's
|
|
112
|
+
* `useParams()` for path params.
|
|
113
|
+
*/
|
|
114
|
+
routes?: RouteEntry[]
|
|
115
|
+
/**
|
|
116
|
+
* Extension fields on built-in resource forms, keyed by form key (e.g.
|
|
117
|
+
* `'product'`). Each field hydrates from the fetched resource via `from`
|
|
118
|
+
* and persists through the form's own Save — render its input from a slot
|
|
119
|
+
* widget bound via `useHostForm()`.
|
|
120
|
+
*/
|
|
121
|
+
formFields?: Record<string, FormFieldRegistration[]>
|
|
122
|
+
/**
|
|
123
|
+
* Input components for specific custom field definitions, keyed by the
|
|
124
|
+
* definition's `namespace.key` (e.g. `'specs.color'`). Replaces the
|
|
125
|
+
* default widget for that definition's `field_type` wherever the
|
|
126
|
+
* custom-fields card renders it.
|
|
127
|
+
*/
|
|
128
|
+
customFieldComponents?: Record<string, CustomFieldComponent>
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Register all the extensions in `config` against the dashboard's registries.
|
|
133
|
+
* Call it once per plugin entry module — each registry enforces uniqueness on
|
|
134
|
+
* its keys, so re-running the same config (or colliding with another plugin's
|
|
135
|
+
* `key`/`id`) throws with a useful message rather than double-registering.
|
|
136
|
+
*
|
|
137
|
+
* Plugins typically call this once from their entry module (e.g.
|
|
138
|
+
* `src/index.ts`). The dashboard app imports plugin entries during bootstrap.
|
|
139
|
+
*
|
|
140
|
+
* @returns nothing — registries are module singletons. To unregister, call the
|
|
141
|
+
* relevant `nav.remove` / `removeSlot` / `tables.<key>.removeColumn` /
|
|
142
|
+
* `settingsNav.remove` directly.
|
|
143
|
+
*/
|
|
144
|
+
export function defineDashboardPlugin(config: DashboardPluginConfig): void {
|
|
145
|
+
// Collect errors across every registry call and rethrow once at the end.
|
|
146
|
+
// A duplicate nav key shouldn't stop the rest of the config (slots, tables,
|
|
147
|
+
// other nav entries) from registering — plugin authors should see every
|
|
148
|
+
// problem in their config at once, not whack-a-mole through reload cycles.
|
|
149
|
+
// Matches the deferred-flush semantics in `table-registry.ts`.
|
|
150
|
+
const errors: unknown[] = []
|
|
151
|
+
const safely = <T extends unknown[]>(fn: (...args: T) => void, ...args: T) => {
|
|
152
|
+
try {
|
|
153
|
+
fn(...args)
|
|
154
|
+
} catch (err) {
|
|
155
|
+
errors.push(err)
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (config.nav) {
|
|
160
|
+
const m: NavMutations = Array.isArray(config.nav) ? { add: config.nav } : config.nav
|
|
161
|
+
// add before addChildren so a plugin can add a parent then nest under it.
|
|
162
|
+
for (const entry of m.add ?? []) safely(nav.add, entry)
|
|
163
|
+
for (const [parentKey, children] of Object.entries(m.addChildren ?? {})) {
|
|
164
|
+
for (const child of children) safely(nav.addChild, parentKey, child)
|
|
165
|
+
}
|
|
166
|
+
for (const key of m.remove ?? []) safely(nav.remove, key)
|
|
167
|
+
for (const [key, patch] of Object.entries(m.update ?? {})) safely(nav.update, key, patch)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (config.settingsNavGroups) {
|
|
171
|
+
for (const group of config.settingsNavGroups) safely(settingsNav.addGroup, group)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
if (config.settingsNav) {
|
|
175
|
+
const m: SettingsNavMutations = Array.isArray(config.settingsNav)
|
|
176
|
+
? { add: config.settingsNav }
|
|
177
|
+
: config.settingsNav
|
|
178
|
+
for (const entry of m.add ?? []) safely(settingsNav.add, entry)
|
|
179
|
+
for (const key of m.remove ?? []) safely(settingsNav.remove, key)
|
|
180
|
+
for (const [key, patch] of Object.entries(m.update ?? {})) {
|
|
181
|
+
safely(settingsNav.update, key, patch)
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (config.slots) {
|
|
186
|
+
for (const [name, list] of Object.entries(config.slots)) {
|
|
187
|
+
for (const entry of list) safely(registerSlot, name, entry)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (config.tables) {
|
|
192
|
+
for (const [tableKey, mutations] of Object.entries(config.tables)) {
|
|
193
|
+
const mutator = tables[tableKey]
|
|
194
|
+
for (const column of mutations.add ?? []) safely(mutator.addColumn, column)
|
|
195
|
+
for (const key of mutations.remove ?? []) safely(mutator.removeColumn, key)
|
|
196
|
+
for (const [key, patch] of Object.entries(mutations.update ?? {})) {
|
|
197
|
+
safely(mutator.updateColumn, key, patch)
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (config.routes) {
|
|
203
|
+
for (const entry of config.routes) safely(pluginRoutes.add, entry)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (config.formFields) {
|
|
207
|
+
for (const [formKey, fields] of Object.entries(config.formFields)) {
|
|
208
|
+
for (const field of fields) safely(formFields.register, formKey, field)
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (config.customFieldComponents) {
|
|
213
|
+
for (const [key, component] of Object.entries(config.customFieldComponents)) {
|
|
214
|
+
safely(customFieldComponents.register, key, component)
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
if (errors.length === 1) throw errors[0]
|
|
219
|
+
if (errors.length > 1) {
|
|
220
|
+
throw new AggregateError(errors, `${errors.length} registration(s) failed`)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
AdminUser,
|
|
3
|
+
AuthTokens,
|
|
4
|
+
InvitationAcceptParams,
|
|
5
|
+
PasswordResetParams,
|
|
6
|
+
} from '@spree/admin-sdk'
|
|
7
|
+
import { createContext, type ReactNode, useCallback, useEffect, useRef, useState } from 'react'
|
|
8
|
+
import { adminClient } from '../client'
|
|
9
|
+
import { ADMIN_LOCALE_STORAGE_KEY, switchLocale } from '../lib/i18n'
|
|
10
|
+
|
|
11
|
+
interface AuthContextValue {
|
|
12
|
+
user: AdminUser | null
|
|
13
|
+
token: string | null
|
|
14
|
+
isAuthenticated: boolean
|
|
15
|
+
isInitializing: boolean
|
|
16
|
+
isLoading: boolean
|
|
17
|
+
login: (email: string, password: string) => Promise<void>
|
|
18
|
+
logout: () => Promise<void>
|
|
19
|
+
acceptInvitation: (id: string, token: string, params: InvitationAcceptParams) => Promise<void>
|
|
20
|
+
/**
|
|
21
|
+
* Consume a password reset token, set the new password, and sign in — the
|
|
22
|
+
* endpoint issues a session just like login. `requestPasswordReset` (the
|
|
23
|
+
* step that sends the email) is unauthenticated and lives on `adminClient`.
|
|
24
|
+
*/
|
|
25
|
+
resetPassword: (token: string, params: PasswordResetParams) => Promise<void>
|
|
26
|
+
/**
|
|
27
|
+
* Merge updated fields into the authenticated user (e.g. after a profile
|
|
28
|
+
* save) so context consumers like the top-bar reflect the change immediately
|
|
29
|
+
* instead of waiting for the next token refresh. No-op when signed out.
|
|
30
|
+
*/
|
|
31
|
+
updateUser: (changes: Partial<AdminUser>) => void
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const AuthContext = createContext<AuthContextValue | null>(null)
|
|
35
|
+
|
|
36
|
+
// Refresh ~30s before the JWT expires (default 5min TTL).
|
|
37
|
+
const REFRESH_INTERVAL_MS = 4 * 60 * 1000 + 30 * 1000
|
|
38
|
+
|
|
39
|
+
export function AuthProvider({ children }: { children: ReactNode }) {
|
|
40
|
+
const [token, setToken] = useState<string | null>(null)
|
|
41
|
+
const [user, setUser] = useState<AdminUser | null>(null)
|
|
42
|
+
const [isInitializing, setIsInitializing] = useState(true)
|
|
43
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
44
|
+
const refreshTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
|
45
|
+
// Serialize concurrent refresh calls so StrictMode/HMR/401-retry don't double-rotate.
|
|
46
|
+
const refreshPromiseRef = useRef<Promise<boolean> | null>(null)
|
|
47
|
+
|
|
48
|
+
const clearRefreshTimer = useCallback(() => {
|
|
49
|
+
if (refreshTimerRef.current) {
|
|
50
|
+
clearTimeout(refreshTimerRef.current)
|
|
51
|
+
refreshTimerRef.current = null
|
|
52
|
+
}
|
|
53
|
+
}, [])
|
|
54
|
+
|
|
55
|
+
const applySession = useCallback((accessToken: string, authUser: AdminUser) => {
|
|
56
|
+
adminClient.setToken(accessToken)
|
|
57
|
+
setToken(accessToken)
|
|
58
|
+
setUser(authUser)
|
|
59
|
+
// The account's saved admin language is the source of truth across devices.
|
|
60
|
+
// Compare against the persisted choice (not the live i18n.language): if they
|
|
61
|
+
// already agree, the page booted in the right language and no reload is
|
|
62
|
+
// needed — this also prevents a reload loop on the periodic token refresh.
|
|
63
|
+
const code = authUser.selected_locale
|
|
64
|
+
const stored =
|
|
65
|
+
typeof localStorage !== 'undefined'
|
|
66
|
+
? (localStorage.getItem(ADMIN_LOCALE_STORAGE_KEY) ?? 'en')
|
|
67
|
+
: 'en'
|
|
68
|
+
if (code && code !== stored) switchLocale(code)
|
|
69
|
+
}, [])
|
|
70
|
+
|
|
71
|
+
const updateUser = useCallback((changes: Partial<AdminUser>) => {
|
|
72
|
+
setUser((current) => (current ? { ...current, ...changes } : current))
|
|
73
|
+
}, [])
|
|
74
|
+
|
|
75
|
+
const clearSession = useCallback(() => {
|
|
76
|
+
adminClient.setToken('')
|
|
77
|
+
setToken(null)
|
|
78
|
+
setUser(null)
|
|
79
|
+
clearRefreshTimer()
|
|
80
|
+
}, [clearRefreshTimer])
|
|
81
|
+
|
|
82
|
+
const doRefresh = useCallback(async (): Promise<boolean> => {
|
|
83
|
+
try {
|
|
84
|
+
const res = await adminClient.auth.refresh()
|
|
85
|
+
applySession(res.token, res.user)
|
|
86
|
+
return true
|
|
87
|
+
} catch {
|
|
88
|
+
clearSession()
|
|
89
|
+
return false
|
|
90
|
+
}
|
|
91
|
+
}, [applySession, clearSession])
|
|
92
|
+
|
|
93
|
+
const refreshAccessToken = useCallback((): Promise<boolean> => {
|
|
94
|
+
if (refreshPromiseRef.current) return refreshPromiseRef.current
|
|
95
|
+
const promise = doRefresh().finally(() => {
|
|
96
|
+
refreshPromiseRef.current = null
|
|
97
|
+
})
|
|
98
|
+
refreshPromiseRef.current = promise
|
|
99
|
+
return promise
|
|
100
|
+
}, [doRefresh])
|
|
101
|
+
|
|
102
|
+
const scheduleRefresh = useCallback(() => {
|
|
103
|
+
clearRefreshTimer()
|
|
104
|
+
refreshTimerRef.current = setTimeout(async () => {
|
|
105
|
+
const success = await refreshAccessToken()
|
|
106
|
+
if (success) scheduleRefresh()
|
|
107
|
+
}, REFRESH_INTERVAL_MS)
|
|
108
|
+
}, [refreshAccessToken, clearRefreshTimer])
|
|
109
|
+
|
|
110
|
+
const establish = useCallback(
|
|
111
|
+
async (req: Promise<AuthTokens>) => {
|
|
112
|
+
setIsLoading(true)
|
|
113
|
+
try {
|
|
114
|
+
const res = await req
|
|
115
|
+
applySession(res.token, res.user)
|
|
116
|
+
scheduleRefresh()
|
|
117
|
+
} finally {
|
|
118
|
+
setIsLoading(false)
|
|
119
|
+
}
|
|
120
|
+
},
|
|
121
|
+
[applySession, scheduleRefresh],
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
const login = useCallback(
|
|
125
|
+
(email: string, password: string) => establish(adminClient.auth.login({ email, password })),
|
|
126
|
+
[establish],
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
const acceptInvitation = useCallback(
|
|
130
|
+
(id: string, token: string, params: InvitationAcceptParams) =>
|
|
131
|
+
establish(adminClient.auth.acceptInvitation(id, token, params)),
|
|
132
|
+
[establish],
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
const resetPassword = useCallback(
|
|
136
|
+
(token: string, params: PasswordResetParams) =>
|
|
137
|
+
establish(adminClient.auth.resetPassword(token, params)),
|
|
138
|
+
[establish],
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
const logout = useCallback(async () => {
|
|
142
|
+
try {
|
|
143
|
+
await adminClient.auth.logout()
|
|
144
|
+
} catch {
|
|
145
|
+
// Server unreachable — clear locally; the row will expire naturally.
|
|
146
|
+
} finally {
|
|
147
|
+
clearSession()
|
|
148
|
+
}
|
|
149
|
+
}, [clearSession])
|
|
150
|
+
|
|
151
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
|
|
152
|
+
useEffect(() => {
|
|
153
|
+
adminClient.onUnauthorized(async () => {
|
|
154
|
+
const success = await refreshAccessToken()
|
|
155
|
+
if (success) scheduleRefresh()
|
|
156
|
+
return success
|
|
157
|
+
})
|
|
158
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
159
|
+
|
|
160
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: only run on mount
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
refreshAccessToken()
|
|
163
|
+
.then((success) => {
|
|
164
|
+
if (success) scheduleRefresh()
|
|
165
|
+
})
|
|
166
|
+
.finally(() => setIsInitializing(false))
|
|
167
|
+
return clearRefreshTimer
|
|
168
|
+
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
|
169
|
+
|
|
170
|
+
return (
|
|
171
|
+
<AuthContext.Provider
|
|
172
|
+
value={{
|
|
173
|
+
user,
|
|
174
|
+
token,
|
|
175
|
+
isAuthenticated: !!token,
|
|
176
|
+
isInitializing,
|
|
177
|
+
isLoading,
|
|
178
|
+
login,
|
|
179
|
+
logout,
|
|
180
|
+
acceptInvitation,
|
|
181
|
+
resetPassword,
|
|
182
|
+
updateUser,
|
|
183
|
+
}}
|
|
184
|
+
>
|
|
185
|
+
{children}
|
|
186
|
+
</AuthContext.Provider>
|
|
187
|
+
)
|
|
188
|
+
}
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
import type { PermissionRule } from '@spree/admin-sdk'
|
|
2
|
+
import { createContext, type ReactNode, useContext, useEffect, useMemo, useState } from 'react'
|
|
3
|
+
import { adminClient } from '../client'
|
|
4
|
+
import { useAuth } from '../hooks/use-auth'
|
|
5
|
+
import type { ActionName, SubjectName } from '../lib/permissions'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Matcher that mirrors CanCanCan semantics:
|
|
9
|
+
* - `manage` action matches any action
|
|
10
|
+
* - `all` subject matches any subject
|
|
11
|
+
* - Later rules override earlier rules (last-matching-wins)
|
|
12
|
+
*
|
|
13
|
+
* Per-record conditions are NOT evaluated — they stay on the server. The
|
|
14
|
+
* `isConditional` flag indicates that a rule may still be rejected at the
|
|
15
|
+
* per-record level, so the UI should expect possible 403 from the API.
|
|
16
|
+
*/
|
|
17
|
+
export interface Permissions {
|
|
18
|
+
can: (action: ActionName, subject: SubjectName) => boolean
|
|
19
|
+
cannot: (action: ActionName, subject: SubjectName) => boolean
|
|
20
|
+
/** True if the matching rule has per-record conditions. Caller should expect possible 403. */
|
|
21
|
+
isConditional: (action: ActionName, subject: SubjectName) => boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
interface PermissionContextValue {
|
|
25
|
+
permissions: Permissions
|
|
26
|
+
rules: PermissionRule[]
|
|
27
|
+
isLoading: boolean
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const PermissionContext = createContext<PermissionContextValue | null>(null)
|
|
31
|
+
|
|
32
|
+
function ruleMatches(rule: PermissionRule, action: string, subject: string): boolean {
|
|
33
|
+
const actionMatch = rule.actions.includes(action) || rule.actions.includes('manage')
|
|
34
|
+
const subjectMatch = rule.subjects.includes(subject) || rule.subjects.includes('all')
|
|
35
|
+
return actionMatch && subjectMatch
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function buildPermissions(rules: PermissionRule[]): Permissions {
|
|
39
|
+
const can = (action: ActionName, subject: SubjectName): boolean => {
|
|
40
|
+
// Walk rules in order — later matching rules override earlier ones
|
|
41
|
+
let allowed = false
|
|
42
|
+
for (const rule of rules) {
|
|
43
|
+
if (ruleMatches(rule, action, subject)) {
|
|
44
|
+
allowed = rule.allow
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
return allowed
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
can,
|
|
52
|
+
cannot: (action, subject) => !can(action, subject),
|
|
53
|
+
isConditional: (action, subject) => {
|
|
54
|
+
let conditional = false
|
|
55
|
+
for (const rule of rules) {
|
|
56
|
+
if (ruleMatches(rule, action, subject) && rule.allow && rule.has_conditions) {
|
|
57
|
+
conditional = true
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return conditional
|
|
61
|
+
},
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Empty permissions that deny everything — used before rules are loaded. */
|
|
66
|
+
const EMPTY_PERMISSIONS: Permissions = {
|
|
67
|
+
can: () => false,
|
|
68
|
+
cannot: () => true,
|
|
69
|
+
isConditional: () => false,
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export function PermissionProvider({ children }: { children: ReactNode }) {
|
|
73
|
+
const { isAuthenticated } = useAuth()
|
|
74
|
+
const [rules, setRules] = useState<PermissionRule[]>([])
|
|
75
|
+
const [isLoading, setIsLoading] = useState(false)
|
|
76
|
+
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (!isAuthenticated) {
|
|
79
|
+
setRules([])
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let cancelled = false
|
|
84
|
+
setIsLoading(true)
|
|
85
|
+
adminClient.me
|
|
86
|
+
.get()
|
|
87
|
+
.then((res) => {
|
|
88
|
+
if (!cancelled) setRules(res.permissions)
|
|
89
|
+
})
|
|
90
|
+
.catch(() => {
|
|
91
|
+
if (!cancelled) setRules([])
|
|
92
|
+
})
|
|
93
|
+
.finally(() => {
|
|
94
|
+
if (!cancelled) setIsLoading(false)
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
return () => {
|
|
98
|
+
cancelled = true
|
|
99
|
+
}
|
|
100
|
+
}, [isAuthenticated])
|
|
101
|
+
|
|
102
|
+
const permissions = useMemo(
|
|
103
|
+
() => (rules.length > 0 ? buildPermissions(rules) : EMPTY_PERMISSIONS),
|
|
104
|
+
[rules],
|
|
105
|
+
)
|
|
106
|
+
|
|
107
|
+
return (
|
|
108
|
+
<PermissionContext.Provider value={{ permissions, rules, isLoading }}>
|
|
109
|
+
{children}
|
|
110
|
+
</PermissionContext.Provider>
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export function usePermissions(): PermissionContextValue {
|
|
115
|
+
const ctx = useContext(PermissionContext)
|
|
116
|
+
if (!ctx) throw new Error('usePermissions must be used within a PermissionProvider')
|
|
117
|
+
return ctx
|
|
118
|
+
}
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { Store } from '@spree/admin-sdk'
|
|
2
|
+
import { useQuery } from '@tanstack/react-query'
|
|
3
|
+
import { createContext, type ReactNode, useContext, useEffect, useRef } from 'react'
|
|
4
|
+
import { adminClient } from '../client'
|
|
5
|
+
import { useAuth } from '../hooks/use-auth'
|
|
6
|
+
import { coreLocaleCodes, reconcileStoreDefaultLocale } from '../lib/i18n'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resource segment of the store query key: the current store lives in the
|
|
10
|
+
* TanStack cache under `['store', storeId]` (the canonical
|
|
11
|
+
* `[resource, storeId]` shape). Mutations that change anything the store
|
|
12
|
+
* payload derives from — setup-task completion, supported locales/currencies —
|
|
13
|
+
* add `[[STORE_QUERY_RESOURCE]]` to their `useResourceMutation` invalidate
|
|
14
|
+
* list so every consumer (nav badge, Getting Started, pickers) refreshes.
|
|
15
|
+
*/
|
|
16
|
+
export const STORE_QUERY_RESOURCE = 'store'
|
|
17
|
+
|
|
18
|
+
interface StoreContextValue {
|
|
19
|
+
store: Store | null
|
|
20
|
+
storeId: string
|
|
21
|
+
isLoading: boolean
|
|
22
|
+
currencies: string[]
|
|
23
|
+
/** Locales this store is configured to translate content into. */
|
|
24
|
+
locales: string[]
|
|
25
|
+
/** Every locale a merchant may translate content into — the full canonical
|
|
26
|
+
* set, independent of which locales the store currently uses. Drives locale
|
|
27
|
+
* pickers so a new locale can always be added. */
|
|
28
|
+
availableLocales: string[]
|
|
29
|
+
defaultCurrency: string
|
|
30
|
+
defaultLocale: string
|
|
31
|
+
/** IANA timezone for the store (e.g. `Europe/Berlin`). Falls back to the
|
|
32
|
+
* browser's resolved timezone when the store hasn't loaded yet. */
|
|
33
|
+
timezone: string
|
|
34
|
+
refetch: () => Promise<unknown>
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
const StoreContext = createContext<StoreContextValue | null>(null)
|
|
38
|
+
|
|
39
|
+
export function StoreProvider({ storeId, children }: { storeId: string; children: ReactNode }) {
|
|
40
|
+
const { user } = useAuth()
|
|
41
|
+
|
|
42
|
+
// Read the account locale through a ref so a `user` identity change (frequent —
|
|
43
|
+
// every auth update) doesn't re-trigger the locale-fallback effect below.
|
|
44
|
+
const accountLocaleRef = useRef<string | null>(user?.selected_locale ?? null)
|
|
45
|
+
accountLocaleRef.current = user?.selected_locale ?? null
|
|
46
|
+
// Run the store-default fallback only on the FIRST load of each store. A
|
|
47
|
+
// later refetch (e.g. after saving store settings) must not re-apply it:
|
|
48
|
+
// that path already owns the locale via `switchAdminLocale`, and re-applying
|
|
49
|
+
// would race that PATCH and could reload before it lands. Tracking the
|
|
50
|
+
// storeId it last ran for re-arms it per store, so a multi-store admin still
|
|
51
|
+
// inherits each store's preferred_admin_locale.
|
|
52
|
+
const localeFallbackStoreRef = useRef<string | null>(null)
|
|
53
|
+
|
|
54
|
+
const query = useQuery({
|
|
55
|
+
// Keyed per store, so a slow in-flight fetch for a store the route already
|
|
56
|
+
// left can never clobber the current store's cache entry.
|
|
57
|
+
queryKey: [STORE_QUERY_RESOURCE, storeId],
|
|
58
|
+
queryFn: () => adminClient.store.get(),
|
|
59
|
+
})
|
|
60
|
+
const store = query.data ?? null
|
|
61
|
+
|
|
62
|
+
// Reconcile the admin language against this store's default — adopting it,
|
|
63
|
+
// or dropping a now-stale auto-applied default — only when no account locale
|
|
64
|
+
// or genuine personal choice owns it (legacy base_controller parity).
|
|
65
|
+
useEffect(() => {
|
|
66
|
+
if (!store || localeFallbackStoreRef.current === storeId) return
|
|
67
|
+
|
|
68
|
+
localeFallbackStoreRef.current = storeId
|
|
69
|
+
reconcileStoreDefaultLocale(
|
|
70
|
+
store.preferred_admin_locale,
|
|
71
|
+
storeId,
|
|
72
|
+
accountLocaleRef.current,
|
|
73
|
+
coreLocaleCodes(),
|
|
74
|
+
)
|
|
75
|
+
}, [store, storeId])
|
|
76
|
+
|
|
77
|
+
const currencies = store?.supported_currencies ?? []
|
|
78
|
+
const locales = store?.supported_locales ?? []
|
|
79
|
+
const availableLocales = store?.available_locales ?? []
|
|
80
|
+
const defaultCurrency = store?.default_currency ?? 'USD'
|
|
81
|
+
const defaultLocale = store?.default_locale ?? 'en'
|
|
82
|
+
const timezone =
|
|
83
|
+
store?.preferred_timezone ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? 'UTC'
|
|
84
|
+
|
|
85
|
+
return (
|
|
86
|
+
<StoreContext.Provider
|
|
87
|
+
value={{
|
|
88
|
+
store,
|
|
89
|
+
storeId,
|
|
90
|
+
isLoading: query.isLoading,
|
|
91
|
+
currencies,
|
|
92
|
+
locales,
|
|
93
|
+
availableLocales,
|
|
94
|
+
defaultCurrency,
|
|
95
|
+
defaultLocale,
|
|
96
|
+
timezone,
|
|
97
|
+
refetch: query.refetch,
|
|
98
|
+
}}
|
|
99
|
+
>
|
|
100
|
+
{children}
|
|
101
|
+
</StoreContext.Provider>
|
|
102
|
+
)
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function useStore(): StoreContextValue {
|
|
106
|
+
const context = useContext(StoreContext)
|
|
107
|
+
if (!context) {
|
|
108
|
+
throw new Error('useStore must be used within a StoreProvider')
|
|
109
|
+
}
|
|
110
|
+
return context
|
|
111
|
+
}
|
package/src/virtual.d.ts
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
// Ambient declaration for the module synthesized by `spreeDashboardPlugin()`
|
|
2
|
+
// (see src/vite/index.ts). Referenced from src/index.ts so any program that
|
|
3
|
+
// imports @spree/dashboard-core can `import 'virtual:spree-dashboard-plugins'`
|
|
4
|
+
// without its own shim.
|
|
5
|
+
declare module 'virtual:spree-dashboard-plugins'
|