@spree/docs 0.1.122 → 0.1.123

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 (29) hide show
  1. package/dist/api-reference/admin-api/endpoints.md +1 -1
  2. package/dist/developer/dashboard/concepts.md +103 -0
  3. package/dist/developer/dashboard/customization/backend.md +126 -0
  4. package/dist/developer/dashboard/customization/navigation.md +211 -0
  5. package/dist/developer/dashboard/customization/permissions.md +123 -0
  6. package/dist/developer/dashboard/customization/quickstart.md +100 -0
  7. package/dist/developer/dashboard/customization/routes.md +187 -0
  8. package/dist/developer/dashboard/customization/slots.md +114 -0
  9. package/dist/developer/dashboard/customization/tables.md +167 -0
  10. package/dist/developer/dashboard/customization/translations.md +96 -0
  11. package/dist/developer/dashboard/deployment.md +80 -0
  12. package/dist/developer/dashboard/overview.md +105 -0
  13. package/dist/developer/dashboard/plugins/distributing.md +137 -0
  14. package/dist/developer/dashboard/plugins/overview.md +46 -0
  15. package/dist/developer/dashboard/plugins/publishing.md +142 -0
  16. package/dist/developer/dashboard/plugins/scaffolding.md +133 -0
  17. package/dist/developer/dashboard/public-api.md +241 -0
  18. package/dist/developer/dashboard/recipes/attribute-end-to-end.md +212 -0
  19. package/dist/developer/dashboard/recipes/custom-form-field.md +174 -0
  20. package/dist/developer/dashboard/recipes/page-action-button.md +189 -0
  21. package/dist/developer/dashboard/recipes/sidebar-widget.md +147 -0
  22. package/dist/developer/dashboard/slots-catalog.md +173 -0
  23. package/dist/developer/sdk/admin/extending.md +70 -0
  24. package/dist/developer/sdk/admin/quickstart.md +3 -3
  25. package/dist/developer/tutorial/admin.md +2 -0
  26. package/dist/developer/tutorial/api.md +2 -0
  27. package/dist/developer/tutorial/extending-models.md +2 -0
  28. package/dist/developer/tutorial/model.md +2 -0
  29. package/package.json +1 -1
@@ -366,7 +366,7 @@ Endpoints marked — are exempt from scope checks (authentication and session en
366
366
 
367
367
  | Method | Path | Required scope | Summary |
368
368
  |---|---|---|---|
369
- | `POST` | `/translations/batch` | *write_<resource> for every resource type in the batch (for API-key authentication)* | Batch upsert translations |
369
+ | `POST` | `/translations/batch` | `write_*` (eg. `write_products`) for every resource type in the batch (for API-key authentication) | Batch upsert translations |
370
370
 
371
371
  ## Webhook endpoints
372
372
 
@@ -0,0 +1,103 @@
1
+ ---
2
+ title: Concepts
3
+ sidebarTitle: Concepts
4
+ description: The mental model — what the dashboard is built on, how the pieces fit together, and what you need to know about each layer before you start customizing.
5
+ ---
6
+
7
+ This page is a five-minute orientation for the dashboard's technology choices and how they cooperate. If you're already familiar with React + TanStack + Tailwind, skim it for the Spree-specific bits and move on.
8
+
9
+ ## Tech stack at a glance
10
+
11
+ | Layer | What we use | Why it matters |
12
+ |---|---|---|
13
+ | Build | [Vite](https://vitejs.dev/) | Fast HMR, dev server, source consumption of TS/TSX |
14
+ | UI runtime | [React 19](https://react.dev/) | Components, hooks, suspense |
15
+ | Routing | [TanStack Router](https://tanstack.com/router) (file-based) | Type-safe URLs, nested layouts, search params |
16
+ | Data | [TanStack Query v5](https://tanstack.com/query) | Caching, refetch, mutation tracking |
17
+ | Forms | [React Hook Form](https://react-hook-form.com/) + [Zod](https://zod.dev/) | Uncontrolled inputs + schema validation |
18
+ | Styling | [Tailwind v4](https://tailwindcss.com/) + [shadcn/ui](https://ui.shadcn.com/) + [Base UI](https://base-ui.com/) | Utility classes + headless primitives |
19
+ | i18n | [i18next](https://www.i18next.com/) | Translations, fallback chains |
20
+ | Lint/format | [Biome](https://biomejs.dev/) | ESLint + Prettier in one tool |
21
+ | HTTP | [`@spree/admin-sdk`](https://www.npmjs.com/package/@spree/admin-sdk) | Typed Admin API client |
22
+
23
+ The dashboard is a **Single Page Application**: one HTML page, all routing client-side. No Rails views, no Turbo, no server-rendered partials. Everything the user sees comes from React components fetching JSON from `/api/v3/admin/*`.
24
+
25
+ ## The three-package split
26
+
27
+ ```
28
+ @spree/dashboard-ui ─ design system: shadcn primitives + headless compounds + tokens
29
+ @spree/dashboard-core ─ framework: registries + providers + hooks + SDK client + plugin facade
30
+ @spree/dashboard ─ app shell: routes, resource hooks, locales, vite config
31
+ ```
32
+
33
+ [Overview](overview.md) covers the split in more detail. The short version: **`-ui`** has no providers or hooks (data comes via props); **`-core`** is the extension API and runtime services; **`-dashboard`** is the deployable app shell. Plugins and customizations consume `-core` and `-ui`, never the app shell.
34
+
35
+ ## How customization plugs in
36
+
37
+ Five global, in-memory registries hold the things you add or modify:
38
+
39
+ | Registry | Adds | Surfaced by |
40
+ |---|---|---|
41
+ | `nav` | Sidebar entries | `<AppSidebar>` |
42
+ | `settingsNav` | Settings sub-shell entries | `<SettingsSidebar>` |
43
+ | Route registry | Custom pages mounted under `/$storeId/*` | Catch-all dispatcher route |
44
+ | Slot registry | Components injected into named slots in built-in pages | `<Slot name="...">` |
45
+ | Table registry | Columns (and their filters) on built-in list tables | `<ResourceTable>` |
46
+
47
+ Each is a module singleton with mutators to add, remove, and patch entries (nav can also insert relative to an existing entry or nest children under one). The consumer components subscribe to changes, so registering late still updates the UI on the next render.
48
+
49
+ The facade `defineDashboardPlugin({ nav, routes, slots, … })` groups all five into one declarative call — convenient for plugins, optional for in-app customizations (you can call `nav.add()` directly).
50
+
51
+ ## Auth, context, providers
52
+
53
+ The signed-in admin's identity and abilities are exposed via three providers wrapping the app:
54
+
55
+ - `<AuthProvider>` — the current admin user; `useAuth()` returns `{ user, signIn, signOut, … }`
56
+ - `<PermissionProvider>` — CanCanCan abilities; `usePermissions()` returns `{ can, cannot }`
57
+ - `<StoreProvider>` — current store + timezone + currency; `useStore()` returns `{ storeId, store, … }`
58
+
59
+ When you write a custom page or hook, pull from these. **Never reach into local state for identity** — it changes on store switch or session refresh, and the providers wire all that for you.
60
+
61
+ ## Data fetching
62
+
63
+ Every screen in the dashboard follows the same pattern:
64
+
65
+ ```
66
+ adminClient (SDK)
67
+ ↓ called inside
68
+ useFoo (custom hook wrapping useQuery)
69
+ ↓ consumed by
70
+ <FooList /> (component renders cached data, owns no fetching logic)
71
+ ```
72
+
73
+ Wrap SDK calls in custom hooks under `src/hooks/`, never call `adminClient` directly from components. See [Backend integration](customization/backend.md) for the full pattern, error mapping, and the `useResourceMutation` helper that wires up 422 handling.
74
+
75
+ ## URL = state
76
+
77
+ The dashboard treats the URL as the single source of truth for page state:
78
+
79
+ - Filters, sort, page number → URL search params
80
+ - Selected row → URL path param
81
+ - "Sheet open?" → URL search param (`?edit=prod_xxx`)
82
+
83
+ This is why links work, deep-links restore filter state, and the browser back button does the right thing. When you build a custom page, follow the same rule — don't stash filter state in React state where it's invisible to bookmarks and analytics.
84
+
85
+ ## i18n is not optional
86
+
87
+ The dashboard ships with English, German, French, Polish, Arabic, and Simplified Chinese out of the box. Every label, placeholder, error message goes through `i18n.t()`. When you add a feature:
88
+
89
+ 1. Add the keys to your customization's `locales/en.json`
90
+ 2. Register the bundle: `i18n.addResourceBundle('en', 'translation', bundle, true, true)`
91
+ 3. Use `useTranslation()` in components and `i18n.t()` at registration sites
92
+
93
+ [Translations](customization/translations.md) covers the field-key convention.
94
+
95
+ ## Prefixed IDs everywhere
96
+
97
+ The Admin API uses [Stripe-style prefixed IDs](../../api-reference/admin-api/introduction.md): `prod_86Rf07xd4z`, `cust_k5nR8xLq`. The dashboard does too — URLs, hook params, table rows, SDK calls. Never coerce IDs to integers. Never strip the prefix. Pass them around as opaque strings.
98
+
99
+ ## Where to go next
100
+
101
+ - [Public API](public-api.md) — what's importable from `@spree/dashboard-core` / `-ui` / `-dashboard`
102
+ - [Customization Quickstart](customization/quickstart.md) — first nav entry in 30 seconds
103
+ - [Recipes](recipes/custom-form-field.md) — focused walkthroughs for common extension shapes
@@ -0,0 +1,126 @@
1
+ ---
2
+ title: Backend integration
3
+ sidebarTitle: Backend integration
4
+ description: Talk to your own (or Spree's) Admin API from the dashboard — typed SDK calls, custom endpoints, prefixed IDs, error handling, and React-Query hooks.
5
+ ---
6
+
7
+ The dashboard never reaches into Rails models or fetches HTML. All data flows through `@spree/admin-sdk` — a typed Admin API client. This page covers the patterns: calling existing endpoints, adding new ones, handling errors, and wrapping it all in React-Query hooks for caching.
8
+
9
+ ## The `adminClient`
10
+
11
+ The SPA boots a single `adminClient` instance and exposes it from `@spree/dashboard-core`. Resource methods follow `client.<resource>.<verb>()`:
12
+
13
+ ```ts
14
+ import { adminClient } from '@spree/dashboard-core'
15
+
16
+ await adminClient.products.list({ filter: { name_cont: 'shirt' } })
17
+ await adminClient.products.get('prod_86Rf07xd4z', { expand: ['variants'] })
18
+ await adminClient.products.create({ name: 'New product' })
19
+ await adminClient.products.update('prod_86Rf07xd4z', { name: 'Renamed' })
20
+ await adminClient.products.delete('prod_86Rf07xd4z')
21
+ ```
22
+
23
+ All IDs are [prefixed](../../../api-reference/admin-api/querying.md) — pass them in, get them out. The SDK never coerces to integers.
24
+
25
+ ## Custom endpoints
26
+
27
+ When you've added an endpoint to `spree/api` that the SDK doesn't model yet (host-app routes, an in-development feature, a custom controller), use the public `request<T>()` escape hatch:
28
+
29
+ ```ts
30
+ import type { AdminBrand } from './types'
31
+
32
+ const data = await adminClient.request<{ data: AdminBrand[] }>(
33
+ 'GET',
34
+ '/brands',
35
+ { params: { 'filter[name_cont]': 'nike' } },
36
+ )
37
+ ```
38
+
39
+ It accepts:
40
+
41
+ - `method` — `'GET' | 'POST' | 'PATCH' | 'PUT' | 'DELETE'`
42
+ - `path` — relative to `/api/v3/admin` (so `/brands` hits `/api/v3/admin/brands`)
43
+ - `body` — JSON-stringified for non-GET requests
44
+ - `params` — query string
45
+
46
+ Resource methods are just `request<T>()` calls with the right path + types pre-bound. When you publish your customization or generate types from your serializer, you can promote the inline call to a typed wrapper.
47
+
48
+ ## React-Query hooks
49
+
50
+ The dashboard's data layer is React-Query — every `useFoo` hook is `useQuery({ queryKey, queryFn })` over an SDK call. Follow the same pattern for your own data:
51
+
52
+ ```ts
53
+ import { adminClient } from '@spree/dashboard-core'
54
+ import { useQuery } from '@tanstack/react-query'
55
+ import type { AdminBrand } from './types'
56
+
57
+ export function useBrands(params?: { search?: string }) {
58
+ return useQuery({
59
+ queryKey: ['brands', params],
60
+ queryFn: () =>
61
+ adminClient.request<{ data: AdminBrand[] }>('GET', '/brands', { params }),
62
+ staleTime: 30_000,
63
+ })
64
+ }
65
+ ```
66
+
67
+ For mutations, prefer the existing `useResourceMutation` helper — it wires up the SDK's error shape, the 422 silencer, and the toast for non-validation errors:
68
+
69
+ ```ts
70
+ import { useResourceMutation } from '@spree/dashboard-core'
71
+
72
+ export function useUpdateBrand(id: string) {
73
+ return useResourceMutation({
74
+ mutationFn: (body: Partial<AdminBrand>) =>
75
+ adminClient.request<{ data: AdminBrand }>('PATCH', `/brands/${id}`, { body }),
76
+ invalidate: [['brands'], ['brand', id]],
77
+ })
78
+ }
79
+ ```
80
+
81
+ The `invalidate` array refetches dependent queries on success.
82
+
83
+ ## Error handling
84
+
85
+ The SDK throws `SpreeError` for non-2xx responses. The error carries `status`, `code`, and `details` — the last contains the Rails error hash for 422s.
86
+
87
+ ```ts
88
+ import { SpreeError } from '@spree/admin-sdk'
89
+
90
+ try {
91
+ await adminClient.products.update(id, payload)
92
+ } catch (err) {
93
+ if (err instanceof SpreeError && err.status === 422) {
94
+ console.log(err.details) // { name: ["can't be blank"] }
95
+ }
96
+ throw err
97
+ }
98
+ ```
99
+
100
+ In a React Hook Form submit handler, prefer the higher-level helper:
101
+
102
+ ```tsx
103
+ import { mapSpreeErrorsToForm } from '@spree/dashboard-core'
104
+
105
+ async function handleSubmit(values: FormValues) {
106
+ try {
107
+ await mutation.mutateAsync(values)
108
+ } catch (err) {
109
+ if (!mapSpreeErrorsToForm(err, form.setError)) throw err
110
+ }
111
+ }
112
+ ```
113
+
114
+ `mapSpreeErrorsToForm` routes flat field errors onto `aria-invalid` `<FieldError>` blocks and `:base` errors onto `errors.root.message` for a destructive banner. Returns `true` if it handled the error; otherwise re-throw so the global toast catches it.
115
+
116
+ ## When to add a new endpoint
117
+
118
+ Pretty much always, for anything non-trivial. If you're filtering, sorting, or aggregating across multiple resources, do it on the backend — the API can join, scope, and serialize correctly in one round-trip, whereas the dashboard would have to fan out N requests and stitch them together. See the backend customization docs for adding controllers, serializers, and authorization.
119
+
120
+ ## Reference
121
+
122
+ - [Admin API reference](../../../api-reference/admin-api/introduction.md) — auth, querying, errors, and every endpoint
123
+ - [`AdminClient.request`](https://github.com/spree/spree/blob/main/packages/admin-sdk/src/admin-client.ts) — full type
124
+ - [`SpreeError`](https://github.com/spree/spree/blob/main/packages/sdk-core/src/errors.ts)
125
+ - [`useResourceMutation`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/hooks/use-resource-mutation.ts)
126
+ - [`mapSpreeErrorsToForm`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/form-errors.ts)
@@ -0,0 +1,211 @@
1
+ ---
2
+ title: Navigation
3
+ sidebarTitle: Navigation
4
+ description: Add, modify, and remove items from the dashboard's main sidebar and settings sub-nav.
5
+ ---
6
+
7
+ The dashboard has two navigation registries: **main sidebar** (`nav`) and **settings sub-shell** (`settingsNav`). Both are module singletons exposed from `@spree/dashboard-core` and consumed by `<AppSidebar>` and `<SettingsSidebar>` via `useSyncExternalStore`, so late registration re-renders the sidebars automatically.
8
+
9
+ ## Main sidebar (`nav`)
10
+
11
+ ### Add an entry
12
+
13
+ ```ts
14
+ import { nav } from '@spree/dashboard-core'
15
+ import { BarChartIcon } from 'lucide-react'
16
+
17
+ nav.add({
18
+ key: 'analytics',
19
+ label: 'Analytics',
20
+ path: '/analytics', // prefixed with /$storeId at render time
21
+ icon: BarChartIcon,
22
+ position: 650,
23
+ subject: 'Spree::Order', // optional CanCanCan subject — hides item without read permission
24
+ })
25
+ ```
26
+
27
+ `position` controls ordering — built-ins use 100–600 (Home 100, Orders 200, Products 300, Customers 400, Promotions 500, Reports 600; a conditional Getting Started entry sits at 50), leaving gaps to slot into. The default is 100. Keys must be unique, including against the built-in keys (`getting-started`, `home`, `orders`, `products`, `customers`, `promotions`, `reports`, `settings`) — duplicates throw at boot.
28
+
29
+ ### Add via `defineDashboardPlugin`
30
+
31
+ The facade groups multiple registry calls:
32
+
33
+ ```ts
34
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
35
+
36
+ defineDashboardPlugin({
37
+ nav: [
38
+ { key: 'analytics', label: 'Analytics', path: '/analytics', icon: BarChartIcon, position: 650 },
39
+ { key: 'segments', label: 'Segments', path: '/segments', icon: UsersIcon, position: 660 },
40
+ ],
41
+ })
42
+ ```
43
+
44
+ ### Nest children under a parent
45
+
46
+ ```ts
47
+ nav.add({
48
+ key: 'analytics',
49
+ label: 'Analytics',
50
+ icon: BarChartIcon,
51
+ position: 650,
52
+ children: [
53
+ { key: 'analytics.sales', label: 'Sales', path: '/analytics/sales' },
54
+ { key: 'analytics.traffic', label: 'Traffic', path: '/analytics/traffic' },
55
+ ],
56
+ })
57
+ ```
58
+
59
+ Children declare their own `subject` independently — the parent doesn't auto-gate them.
60
+
61
+ ### Nest under an existing (built-in) parent
62
+
63
+ To add an item **inside** a menu you don't own — e.g. a "Brands" page under the built-in **Products** menu — use `addChild`, which preserves the parent's existing children. (`nav.update('products', { children: [...] })` would *replace* them, dropping the built-ins.)
64
+
65
+ ```ts
66
+ nav.addChild('products', {
67
+ key: 'products.brands',
68
+ label: 'Brands',
69
+ path: '/products/brands',
70
+ subject: 'Spree::Brand',
71
+ })
72
+ ```
73
+
74
+ Or declaratively, keyed by parent:
75
+
76
+ ```ts
77
+ defineDashboardPlugin({
78
+ nav: {
79
+ addChildren: {
80
+ products: [{ key: 'products.brands', label: 'Brands', path: '/products/brands' }],
81
+ },
82
+ },
83
+ })
84
+ ```
85
+
86
+ Nesting under a missing parent throws; a duplicate child key throws (`updateChild` / `removeChild` mutate an existing one). Built-in parents (`products`, `orders`, `customers`, `promotions`) register during app bootstrap, before any plugin runs, so they're always available to nest into.
87
+
88
+ ### Insert relative to an existing entry
89
+
90
+ ```ts
91
+ nav.insertBefore('customers', { key: 'segments', label: 'Segments', path: '/segments' })
92
+ nav.insertAfter('orders', { key: 'returns', label: 'Returns', path: '/returns' })
93
+ ```
94
+
95
+ The new entry inherits the target's `position` ± 1 unless you specify your own.
96
+
97
+ ### Modify or remove
98
+
99
+ Imperatively:
100
+
101
+ ```ts
102
+ nav.update('orders', { label: 'All orders' })
103
+ nav.remove('legacy-thing')
104
+ ```
105
+
106
+ Or declaratively in `defineDashboardPlugin` — the `nav` object form adds, removes, and patches in one config (built-in entries included; they register before any plugin runs):
107
+
108
+ ```ts
109
+ defineDashboardPlugin({
110
+ nav: {
111
+ add: [{ key: 'reviews', label: 'Reviews', path: '/reviews', position: 650 }],
112
+ remove: ['promotions'],
113
+ update: { products: { label: 'Catalog', position: 150 } },
114
+ },
115
+ })
116
+ ```
117
+
118
+ The array form (`nav: [...]`) remains the shorthand for `{ add: [...] }`. The object form also takes `addChildren` (see [Nest under an existing parent](#nest-under-an-existing-built-in-parent)). `remove` of an unknown key is a no-op; `update` of an unknown key throws. `settingsNav` accepts the same object form (minus `addChildren` — settings entries are flat).
119
+
120
+ ### Bottom-pinned entries
121
+
122
+ `section: 'bottom'` pins to the sidebar footer (where "Settings" lives by default):
123
+
124
+ ```ts
125
+ nav.add({
126
+ key: 'help',
127
+ label: 'Help',
128
+ path: '/help',
129
+ icon: HelpCircleIcon,
130
+ section: 'bottom',
131
+ })
132
+ ```
133
+
134
+ ### Conditional visibility and badges
135
+
136
+ Beyond permission gating with `subject`, a top-level entry can hide itself based on app state (`if`) and render a component after its label (`badge`):
137
+
138
+ ```tsx
139
+ nav.add({
140
+ key: 'onboarding',
141
+ label: 'Onboarding',
142
+ path: '/onboarding',
143
+ // Receives the current store/user/permissions; return false to hide.
144
+ if: ({ store }) => !storeFullyConfigured(store),
145
+ // A component, not an element — it can call hooks and return null.
146
+ badge: PendingTasksBadge,
147
+ })
148
+ ```
149
+
150
+ The built-in Getting Started entry works exactly this way: it shows a remaining-tasks count and disappears once every setup task is done. `if` combines with `subject` — both must pass.
151
+
152
+ ## Settings sub-nav (`settingsNav`)
153
+
154
+ The settings page has its own sub-shell sidebar with grouped entries (Store, Localization, Team & Access, …). Entries cluster under groups defined separately.
155
+
156
+ ### Register a group
157
+
158
+ ```ts
159
+ import { settingsNav } from '@spree/dashboard-core'
160
+
161
+ settingsNav.addGroup({
162
+ key: 'integrations',
163
+ label: 'Integrations',
164
+ position: 500,
165
+ })
166
+ ```
167
+
168
+ ### Add entries to a group
169
+
170
+ ```ts
171
+ settingsNav.add({
172
+ key: 'stripe-tax',
173
+ label: 'Stripe Tax',
174
+ path: '/integrations/stripe-tax', // prefixed with /$storeId/settings
175
+ group: 'integrations',
176
+ position: 100,
177
+ subject: 'Spree::TaxRate',
178
+ })
179
+ ```
180
+
181
+ ### `comingSoon` badge
182
+
183
+ Disabled entries with a "Soon" badge — useful for staging the rollout:
184
+
185
+ ```ts
186
+ settingsNav.add({
187
+ key: 'reports',
188
+ label: 'Reports',
189
+ path: '/reports',
190
+ group: 'analytics',
191
+ comingSoon: true,
192
+ })
193
+ ```
194
+
195
+ ## Permission gating
196
+
197
+ `subject` on a nav entry checks `permissions.can('read', subject)` — when it fails, the sidebar item is hidden. **This is UX, not authorization.** The backend still enforces CanCanCan via `authorize!` on every API call. Hiding the link is a hint, not a security boundary.
198
+
199
+ ## Order of operations
200
+
201
+ Nav registrations run at module-load time. The sidebar reads via `useSyncExternalStore`, so:
202
+
203
+ - Registering before the sidebar mounts: works (the snapshot picks it up on first render)
204
+ - Registering after the sidebar mounts: also works (the store notifies subscribers)
205
+
206
+ The only ordering rule is **i18n**: if you use `i18n.t('admin.foo.label')` for `label`, your translation bundle must be registered first. The framework's own translations load before `src/plugins.ts` runs, so the rule reduces to: put your `i18n.addResourceBundle(...)` call at the top of `plugins.ts`, above the registrations that use it. See [Translations](translations.md).
207
+
208
+ ## Reference
209
+
210
+ - [`NavEntry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/nav-registry.ts) — the full shape including types for `children`, `position`, `subject`, `section`
211
+ - [`SettingsNavEntry`](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/lib/settings-nav-registry.ts) — settings sub-nav types
@@ -0,0 +1,123 @@
1
+ ---
2
+ title: Permissions
3
+ sidebarTitle: Permissions
4
+ description: How to hide UI behind CanCanCan permissions in your customisations — and why hiding is never the same as authorising.
5
+ ---
6
+
7
+ The dashboard exposes the current admin's CanCanCan abilities to every component via the `usePermissions()` hook. The registry surfaces — nav entries, settings entries, custom routes — also accept a `subject` shortcut, and nav entries take a generic `if` predicate that reads from `permissions`.
8
+
9
+ ## Two layers
10
+
11
+ UI gating is for **UX**. Backend authorization is for **security**. They are not the same:
12
+
13
+ | Layer | Where | What it does |
14
+ |---|---|---|
15
+ | UI gating | Dashboard registries (`subject`, `if`) | Hides menu items, columns, buttons — keeps the interface tidy |
16
+ | Authorization | Rails API controllers (CanCanCan, scopes) | Refuses the request when the user doesn't have permission |
17
+
18
+ **Always rely on the backend for security.** Hiding a button is a hint to the user, not a wall. A user with browser dev tools (or the API token) can always hit the endpoint directly — your `authorize!` call is what stops them.
19
+
20
+ ## The `permissions` object
21
+
22
+ ```ts
23
+ interface Permissions {
24
+ can(action: ActionName, subject: SubjectName): boolean
25
+ cannot(action: ActionName, subject: SubjectName): boolean
26
+ /** True when the matching rule has per-record conditions — expect possible 403s. */
27
+ isConditional(action: ActionName, subject: SubjectName): boolean
28
+ }
29
+ ```
30
+
31
+ It mirrors the backend ability at the **class level**:
32
+
33
+ ```ts
34
+ permissions.can('read', 'Spree::Order')
35
+ ```
36
+
37
+ Subjects are strings (`'Spree::Order'`, `'Spree::Product'`, or your own `'MyApp::Report'`). There is no client-side record-level check — when a rule is conditional on record attributes, `isConditional` returns `true` and the API is the arbiter: render the control and handle a possible 403.
38
+
39
+ ## `subject` shortcut
40
+
41
+ Available on `NavEntry`, `SettingsNavEntry`, and `RouteEntry`. It hides the entry unless the user can `read` the subject:
42
+
43
+ ```ts
44
+ nav.add({
45
+ key: 'reports',
46
+ label: 'Reports',
47
+ path: '/reports',
48
+ subject: 'Spree::Order', // hides nav item without read:Spree::Order
49
+ })
50
+ ```
51
+
52
+ For routes, `subject` also renders a 403 page if the user navigates directly:
53
+
54
+ ```ts
55
+ routes: [{
56
+ key: 'reports',
57
+ path: '/reports',
58
+ component: ReportsPage,
59
+ subject: 'Spree::Order',
60
+ }],
61
+ ```
62
+
63
+ Use `subject` whenever you want a "user can read this resource" check. For anything else, use `if`.
64
+
65
+ ## `if` predicate (nav entries)
66
+
67
+ `if` on a nav entry is the escape hatch. It runs at render with `{ permissions, store, user }` and returns a boolean:
68
+
69
+ ```tsx
70
+ import type { Store } from '@spree/admin-sdk'
71
+
72
+ nav.add({
73
+ key: 'reports',
74
+ label: 'Reports',
75
+ path: '/reports',
76
+ if: ({ permissions, store }) =>
77
+ permissions.can('read', 'Spree::Order') &&
78
+ !!(store as Store | null)?.setup_tasks?.every((task) => task.done),
79
+ })
80
+ ```
81
+
82
+ (The `if` context types `store` loosely — cast to the SDK's `Store` for typed access, the same way the built-in Getting Started entry does.)
83
+
84
+ Use it for combined checks (permission + store state), feature flags, multi-action checks (`can(update) && can(read)`), or anything the `subject` shortcut can't express in one string. It combines with `subject` — both must pass. (Slot entries have an `if` too, but it receives only the slot's own context — see [Slots](slots.md).)
85
+
86
+ ## Inside components
87
+
88
+ Use the `usePermissions()` hook — it returns `{ permissions, rules, isLoading }`:
89
+
90
+ ```tsx
91
+ import { usePermissions } from '@spree/dashboard-core'
92
+
93
+ function ReportsPage() {
94
+ const { permissions } = usePermissions()
95
+ if (!permissions.can('read', 'Spree::Order')) {
96
+ return <Forbidden />
97
+ }
98
+ return <ReportsList />
99
+ }
100
+ ```
101
+
102
+ The same `permissions` object backs the nav registry's `if` predicate, so you can move logic between the two without changing behaviour. For declarative gating, `<Can I="update" a="Spree::Order">…</Can>` (also from `@spree/dashboard-core`) renders children only when the check passes.
103
+
104
+ ## Custom abilities
105
+
106
+ If your customization introduces a new model on the backend, extend the CanCanCan ability there (typically a decorator on `Spree::PermissionSets::DefaultCustomer` or `Spree::PermissionSets::DashboardManagement`):
107
+
108
+ ```ruby
109
+ # config/initializers/spree.rb (host app)
110
+ Spree::PermissionSets::DashboardManagement.class_eval do
111
+ def activate!
112
+ super
113
+ can :manage, MyApp::Report
114
+ end
115
+ end
116
+ ```
117
+
118
+ After that, `permissions.can('read', 'MyApp::Report')` resolves in the dashboard exactly like a first-party check — the abilities ship to the dashboard with the current-user response (`GET /api/v3/admin/me`) at sign-in.
119
+
120
+ ## Reference
121
+
122
+ - [`Permissions` interface](https://github.com/spree/spree/blob/main/packages/dashboard-core/src/providers/permission-provider.tsx)
123
+ - [CanCanCan docs](https://github.com/CanCanCommunity/cancancan) — the Ruby ability definition language
@@ -0,0 +1,100 @@
1
+ ---
2
+ title: Quickstart
3
+ sidebarTitle: Quickstart
4
+ description: Add your first customization to the Spree dashboard in under 5 minutes — no npm publishing, no build configuration, just one file in your dashboard app.
5
+ ---
6
+
7
+ This guide assumes two things are running:
8
+
9
+ 1. A Spree API server (your Spree store's backend).
10
+ 2. Your dashboard app — the small Vite project that renders the admin. In a `create-spree-app` project it lives at `apps/dashboard/`; add it to an existing project with `npx spree add dashboard`, or scaffold it anywhere with `npx spree add dashboard --template` pointing at your own copy. Start it with `pnpm dev`.
11
+
12
+ All customizations in this guide are edits to your dashboard app's own code. **You don't need to scaffold a plugin** — plugins are for [distributing features to other stores](../plugins/overview.md).
13
+
14
+ ## The 30-second example
15
+
16
+ Add an "Analytics" item to the dashboard sidebar.
17
+
18
+ **Step 1.** Open `src/plugins.ts` — the starter ships this file already wired up — and register a nav entry:
19
+
20
+ ```tsx
21
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
22
+ import { BarChartIcon } from 'lucide-react'
23
+
24
+ defineDashboardPlugin({
25
+ nav: [{
26
+ key: 'analytics',
27
+ label: 'Analytics',
28
+ path: '/analytics',
29
+ icon: BarChartIcon,
30
+ // Built-in entries use positions 100–600 (Home … Reports).
31
+ // 650 places Analytics after Reports, at the end.
32
+ position: 650,
33
+ }],
34
+ })
35
+ ```
36
+
37
+ **Step 2.** Save. The dev server hot-reloads and the sidebar now shows "Analytics".
38
+
39
+ Clicking it lands on a "Page not found" screen — we registered the nav entry but no page. Let's fix that.
40
+
41
+ ## Add the page
42
+
43
+ **Step 3.** Create the page component at `src/pages/analytics.tsx`:
44
+
45
+ ```tsx
46
+ import { PageHeader } from '@spree/dashboard-core'
47
+ import { ResourceLayout } from '@spree/dashboard-ui'
48
+
49
+ export function AnalyticsPage() {
50
+ return (
51
+ <ResourceLayout
52
+ header={<PageHeader title="Analytics" subtitle="Store performance over time" />}
53
+ main={<p>Coming soon.</p>}
54
+ />
55
+ )
56
+ }
57
+ ```
58
+
59
+ **Step 4.** Register the route in `src/plugins.ts`:
60
+
61
+ ```tsx
62
+ import { defineDashboardPlugin } from '@spree/dashboard-core'
63
+ import { BarChartIcon } from 'lucide-react'
64
+ import { AnalyticsPage } from './pages/analytics'
65
+
66
+ defineDashboardPlugin({
67
+ nav: [{
68
+ key: 'analytics',
69
+ label: 'Analytics',
70
+ path: '/analytics',
71
+ icon: BarChartIcon,
72
+ position: 650,
73
+ }],
74
+ routes: [{
75
+ key: 'analytics',
76
+ path: '/analytics',
77
+ component: AnalyticsPage,
78
+ }],
79
+ })
80
+ ```
81
+
82
+ Save. Click "Analytics" — your page renders inside the dashboard chrome, at `/<store id>/analytics`.
83
+
84
+ > **WARNING:** Every `key` must be unique, including against the built-ins (`getting-started`, `home`, `orders`, `products`, `customers`, `promotions`, `reports`, `settings`). Registering a duplicate key throws at boot with a message naming the conflict.
85
+
86
+ ## What just happened
87
+
88
+ `defineDashboardPlugin` registers extensions against the dashboard's shared registries: `nav`, `routes`, `slots`, `tables`, `settingsNav`, `formFields`, and `customFieldComponents`. Your `src/plugins.ts` runs when the app boots (the starter's `main.tsx` imports it), so everything is registered before the first render. These are the exact same APIs distributable plugins use — the only difference is packaging.
89
+
90
+ The custom route mounts under `/<store id>/analytics`. Routes registered this way are matched at navigation time, so they can even be registered lazily after boot.
91
+
92
+ Building a **distributable plugin** rather than customizing your own app? Ship pages as [file routes](routes.md) instead — they compile into the app's route tree, so links to them are type-checked. The registry form shown here is for in-app customization.
93
+
94
+ ## Next steps
95
+
96
+ - [Navigation](navigation.md) — sidebar entries, nesting under built-in menus, settings sub-nav, permission gating
97
+ - [Routes](routes.md) — path patterns, params, permission fallback
98
+ - [Slots](slots.md) — inject widgets into built-in pages without forking them
99
+ - [Tables](tables.md) — define your own list page or extend built-in tables
100
+ - [Translations](translations.md) — add i18n keys without colliding with the framework