@netsapiens/horizon-sdk 0.1.5 → 0.1.7

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/README.md CHANGED
@@ -19,9 +19,169 @@ This package is for **third-party developers**. It provides:
19
19
  npm install @netsapiens/horizon-sdk
20
20
  ```
21
21
 
22
- Peer deps: `react ^18 || ^19`, `react-dom ^18 || ^19`, `loglevel ^1.9`.
22
+ Peer deps: `react ^19`, `react-dom ^19`, `loglevel ^1.9`.
23
23
 
24
- > Do **not** add `@mui/material`, `@emotion/*`, or `@mui/x-data-grid-pro` to your `package.json` or your webpack `shared` config. The host's federation loader does not expose them as shared modules — declaring them as singletons causes a version-mismatch crash at load time. Use all MUI components via `horizonContext.ui` instead; that is how Aurora theme and dark mode reach your app.
24
+ > Do **not** add `@mui/material`, `@emotion/*`, or `@mui/x-data-grid-pro` to your `package.json` or your webpack `shared` config. The host's federation loader does not expose them as shared modules — declaring them as singletons causes a version-mismatch crash at load time. Use all MUI components via `horizonContext.ui` instead; that is how theme and dark mode reach your app.
25
+
26
+ ---
27
+
28
+ ## What's in `horizonContext`
29
+
30
+ Your `App` component receives this object as props; page components read it with `useHorizonContext()`.
31
+
32
+ | Field | Notes |
33
+ | ---------------- | ------------------------------------------------------------------------------------------------------- |
34
+ | `user` | `displayName`, `domain`, `email?`, `extension?`, `scope?`, `department?`, `site?` (+ extras) |
35
+ | `auth` | `isAuthenticated()`, plus the remote-auth methods (see [Remote authentication](#remote-authentication)) |
36
+ | `api` | Authenticated NetSapiens v2 client (`get`/`post`/`put`/`delete`/`getBaseUrl`) |
37
+ | `theme` | `'light'` \| `'dark'` (reactive) |
38
+ | `locale` | e.g. `'en-US'` (reactive) |
39
+ | `t(key, opts?)` | Host i18next translation function — all host strings are available, no setup needed |
40
+ | `navigate(path)` | Pushes a route in the host router |
41
+ | `eventBus` | `emit`/`on`/`off` for cross-app messages |
42
+ | `ui` | Pre-themed components and templates (see below) |
43
+ | `breadcrumbs` | Current breadcrumb trail (optional) |
44
+
45
+ ---
46
+
47
+ ## Using `horizonContext.ui`
48
+
49
+ Don't ship MUI yourself — render Horizon's themed components instead. They inherit the host's MUI theme, including automatic dark/light mode switching.
50
+
51
+ > **Styling is handled by the SDK UI components.** They inherit the host theme (dark mode included) and sensible layout defaults, so you rarely set styling yourself.
52
+
53
+ > **Renders are isolated.** Each extension and side panel you render is wrapped in an error boundary by the host — a render error in your component shows a fallback instead of crashing the host page.
54
+
55
+ Page components read the context with `useHorizonContext()`; extension components receive it as a `context` prop:
56
+
57
+ ```tsx
58
+ // Page component — read the context with the hook
59
+ import { useHorizonContext } from "@netsapiens/horizon-sdk";
60
+
61
+ export default function MyPage() {
62
+ const { ui, user } = useHorizonContext();
63
+ const { PageTemplate, DatagridTemplate } = ui.templates;
64
+ const { Button, Stack, Typography } = ui;
65
+
66
+ return (
67
+ <PageTemplate
68
+ title="My Page"
69
+ breadcrumbs={[{ label: "Apps", url: "/apps" }]}
70
+ >
71
+ <Stack spacing={2}>
72
+ <Typography variant="h5">Hello, {user.displayName}</Typography>
73
+ <Button variant="contained">Action</Button>
74
+ </Stack>
75
+ </PageTemplate>
76
+ );
77
+ }
78
+ ```
79
+
80
+ ```tsx
81
+ // Extension component — the host passes `context`
82
+ function MyButton({ context }: ExtensionComponentProps) {
83
+ const { Button } = context.ui ?? {};
84
+ return <Button variant="contained">Click</Button>;
85
+ }
86
+ ```
87
+
88
+ Available under `horizonContext.ui` (or via `useHorizonContext().ui`):
89
+
90
+ - `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `SidePanel`, `FormPanel`, `DatagridTemplate`
91
+
92
+ > **`DatagridTemplate` and the MUI X Pro license** — `DatagridTemplate` wraps MUI's `DataGridPro` internally, which is a paid licensed component. The license is held by the Horizon host; **you do not need an MUI X Pro license** and must not import `@mui/x-data-grid-pro` directly in your remote app. Always use `DatagridTemplate` from the SDK.
93
+
94
+ - Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`, `FormControlLabel`
95
+ - Display: `Typography`, `Chip`, `Avatar`, `Divider`, `Tooltip`, `Icon`
96
+ - Layout: `Stack`, `Paper`
97
+ - Feedback: `Alert`
98
+ - `theme` (`'light' | 'dark'`, reactive via `useHorizonContext()`) and `styles` for derived tokens
99
+
100
+ #### IconButton — shorthand-only API
101
+
102
+ `IconButton` does **not** accept the standard MUI children pattern. It is a shorthand wrapper that takes the icon name as a string prop:
103
+
104
+ ```tsx
105
+ // ✅ Correct — pass the Iconify name via the `icon` prop
106
+ <IconButton icon="mdi:account" aria-label="Account" />
107
+ <IconButton icon="material-symbols:bolt" iconSize={18} size="small" onClick={handleClick} />
108
+
109
+ // ❌ Wrong — children are dropped, the button renders empty at 0×0
110
+ <IconButton aria-label="Account">
111
+ <Icon icon="mdi:account" />
112
+ </IconButton>
113
+ ```
114
+
115
+ The TypeScript types reject the children pattern at compile time. If you ever see a 0×0 button in the DOM, this is the first thing to check.
116
+
117
+ Other themed components (`Button`, `Stack`, `Paper`, etc.) follow the standard MUI children pattern.
118
+
119
+ #### SidePanel & FormPanel — right-side drawers
120
+
121
+ `SidePanel` and `FormPanel` are the shared right-drawer components Horizon uses for its own add/edit forms and detail panels. Reach for these when building a right-side drawer so it matches the host exactly and picks up the `form-section-*` extension zones automatically.
122
+
123
+ **`SidePanel`** — the drawer shell (sticky header, scrollable body, optional sticky footer). Use for detail/view/settings panels:
124
+
125
+ ```tsx
126
+ const { SidePanel } = ui.templates;
127
+
128
+ <SidePanel
129
+ open={open}
130
+ onClose={() => setOpen(false)}
131
+ title="Details"
132
+ subtitle="Read-only"
133
+ width="lg" // 'sm' | 'md' | 'lg' | 'xl'
134
+ footer={<Button onClick={() => setOpen(false)}>Close</Button>}
135
+ >
136
+ {/* body */}
137
+ </SidePanel>;
138
+ ```
139
+
140
+ **`FormPanel`** — `SidePanel` + a `<form>`, a Submit/Cancel footer, and the `form-section-before` / `form-section-after` extension zones built in:
141
+
142
+ ```tsx
143
+ const { FormPanel } = ui.templates;
144
+
145
+ <FormPanel
146
+ open={open}
147
+ onClose={() => setOpen(false)}
148
+ title="Add widget"
149
+ formType="widget" // identifies the form to extensions
150
+ mode="add" // 'add' | 'edit'
151
+ formData={values} // live values handed to extensions
152
+ onSubmit={(e) => {
153
+ e?.preventDefault();
154
+ save(values);
155
+ }}
156
+ submitLabel="Add"
157
+ isSubmitting={pending}
158
+ error={error}
159
+ >
160
+ {/* field sections */}
161
+ </FormPanel>;
162
+ ```
163
+
164
+ **Multi-step wizard** — pass `steps` instead of `children`; FormPanel renders the stepper header and a Back/Next/Submit footer. Each step's optional `validate` gates forward navigation (return `boolean | Promise<boolean>`):
165
+
166
+ ```tsx
167
+ <FormPanel
168
+ open={open}
169
+ onClose={() => setOpen(false)}
170
+ title="Setup"
171
+ formType="setup"
172
+ mode="add"
173
+ onSubmit={handleSubmit(onValid)}
174
+ isComplete={isValid} // offer Submit before the last step
175
+ steps={[
176
+ {
177
+ label: "Basics",
178
+ content: <BasicsStep />,
179
+ validate: () => trigger(["name"]),
180
+ },
181
+ { label: "Options", content: <OptionsStep /> },
182
+ ]}
183
+ />
184
+ ```
25
185
 
26
186
  ---
27
187
 
@@ -34,7 +194,9 @@ import { type HorizonContext, useRemoteApp } from "@netsapiens/horizon-sdk";
34
194
  import MyButton from "./extensions/MyButton";
35
195
 
36
196
  export default function App(horizonContext: HorizonContext) {
37
- const { sdk, user, theme } = useRemoteApp(horizonContext, "my-app");
197
+ // "myApp" === the name in your ModuleFederationPlugin config (webpack_module).
198
+ // The SDK derives the kebab app id ("my-app") used for registry attribution.
199
+ const { sdk, user, theme } = useRemoteApp(horizonContext, "myApp");
38
200
 
39
201
  useEffect(() => {
40
202
  sdk.registerDynamicExtension({
@@ -75,21 +237,6 @@ export default function MyButton({ context }: ExtensionComponentProps) {
75
237
 
76
238
  ---
77
239
 
78
- ## What's in `horizonContext`
79
-
80
- | Field | Notes |
81
- | ---------------- | ----------------------------------------------------------------------------- |
82
- | `user` | `displayName`, `domain`, `email?`, plus host-defined extras |
83
- | `auth` | `isAuthenticated()` |
84
- | `api` | Authenticated NetSapiens v2 client (`get`/`post`/`put`/`delete`/`getBaseUrl`) |
85
- | `theme` | `'light'` \| `'dark'` |
86
- | `locale` | e.g. `'en-US'` |
87
- | `navigate(path)` | Pushes a route in the host router |
88
- | `eventBus` | `emit`/`on`/`off` for cross-app messages |
89
- | `ui` | Pre-themed components and templates (see below) |
90
-
91
- ---
92
-
93
240
  ## Registration APIs
94
241
 
95
242
  ### Routes — full pages added to Horizon
@@ -119,7 +266,7 @@ const MySettingsRoute = useMemo(
119
266
 
120
267
  sdk.registerRoute({
121
268
  id: "my-app.settings",
122
- parentPath: "/home", // attach under Apps menu
269
+ parentPath: "/home", // attach under the My Account menu
123
270
  path: "my-settings", // → /home/my-settings
124
271
  label: "My Settings",
125
272
  icon: "mdi:cog",
@@ -128,39 +275,14 @@ sdk.registerRoute({
128
275
  });
129
276
  ```
130
277
 
131
- #### Menu placement
132
-
133
- Use `placement` to control where your route appears in the menu. You can position relative to existing menu items using semantic anchors:
134
-
135
- ```tsx
136
- sdk.registerRoute({
137
- id: "my-app.settings",
138
- parentPath: "/home",
139
- path: "my-settings",
140
- label: "My Settings",
141
- icon: "mdi:cog",
142
- placement: { after: "settings" }, // After the Settings item
143
- component: MySettingsRoute,
144
- });
145
- ```
146
-
147
- **Placement options:**
148
-
149
- - `{ after: 'anchor-id' }` — Place immediately after the specified item
150
- - `{ before: 'anchor-id' }` — Place immediately before the specified item
151
- - `{ first: true }` — Force to the start of the menu
152
- - `{ last: true }` — Force to the end of the menu (default if no placement specified)
153
-
154
- The system uses fuzzy matching with a 0.8 similarity threshold, so minor typos (e.g., `contats` instead of `contacts`) will still work. If the anchor isn't found, your item is placed at the end of the menu with a console warning.
155
-
156
- Inside the page component, read context with `useHorizonContext()` instead of accepting it as props:
278
+ Inside the page component, read context with `useHorizonContext()`:
157
279
 
158
280
  ```tsx
159
281
  // src/pages/MySettingsPage.tsx
160
282
  import { useHorizonContext } from "@netsapiens/horizon-sdk";
161
283
 
162
284
  export default function MySettingsPage() {
163
- const { ui, user, navigate, theme } = useHorizonContext();
285
+ const { ui, user, navigate } = useHorizonContext();
164
286
  const { PageTemplate } = ui.templates;
165
287
  const { Button, Stack, Typography } = ui;
166
288
 
@@ -183,25 +305,36 @@ export default function MySettingsPage() {
183
305
  The `useRoute` hook handles register + unregister automatically:
184
306
 
185
307
  ```tsx
186
- useRoute(horizonContext.eventBus, 'my-app', { id: '...', /* ... */, component: MySettingsRoute });
308
+ useRoute(horizonContext.eventBus, 'myApp', { id: '...', /* ... */, component: MySettingsRoute });
187
309
  ```
188
310
 
189
- If your route component lives in a separate exposed module, use `useRouteFromModule`:
311
+ If your route's page component is **exposed as a separate Module Federation module** in your own bundle (rather than imported into your entry `App`), use `useRouteFromModule` — it loads that exposed module from your container and registers it as a route. (This is for splitting up your _own_ app's code; it does not pull a component from another app.)
190
312
 
191
313
  ```tsx
192
314
  const { loading, error } = useRouteFromModule(
193
315
  horizonContext.eventBus,
194
- "my-app",
316
+ "myApp",
195
317
  {
196
318
  id: "my-app.settings",
197
319
  parentPath: "/home",
198
320
  path: "my-settings",
199
321
  label: "My Settings",
200
322
  },
201
- { scope: "myApp", module: "./SettingsPage" },
323
+ { scope: "myApp", module: "./SettingsPage" }, // an exposed module in YOUR webpack config
202
324
  );
203
325
  ```
204
326
 
327
+ #### Menu placement
328
+
329
+ Use `placement` to control where your route appears in the menu, relative to existing menu items:
330
+
331
+ - `{ after: 'anchor-id' }` — Place immediately after the specified item
332
+ - `{ before: 'anchor-id' }` — Place immediately before the specified item
333
+ - `{ first: true }` — Force to the start of the menu
334
+ - `{ last: true }` — Force to the end of the menu (default if no placement specified)
335
+
336
+ The anchor is the human-readable id/label of an existing item (e.g. `'contacts'`, `'devices'`, `'settings'`). The host resolves it with **fuzzy matching**, so minor differences and typos (e.g. `contats`) still match. If no anchor matches, your item is placed at the end of the menu with a console warning.
337
+
205
338
  ### Dynamic extensions — inject UI into host pages
206
339
 
207
340
  Extensions render at named **zones** on routes that match a **pattern**. You don't need the host page to opt in.
@@ -218,23 +351,22 @@ sdk.registerDynamicExtension({
218
351
  });
219
352
  ```
220
353
 
221
- **Available zones:**
354
+ **Available zones** (the zones the host mounts today):
222
355
 
223
- | Zone | Where it renders |
224
- | ----------------------- | ------------------------------------------------------------------------------ |
225
- | `page-header-actions` | Right side of any page header |
226
- | `page-header-secondary` | Subtitle row of page header (badges, status) |
227
- | `page-content-after` | Below main page content |
228
- | `sidetray` | Side tray panel (open/close via host toggle) |
229
- | `table-toolbar` | Above any DataGrid |
230
- | `table-filter-bar` | Filter chips inline with search bar; pass a row predicate via `onFilterChange` |
231
- | `table-row-actions` | Per-row action column |
232
- | `detail-panel-tabs` | Detail-panel tab strip |
233
- | `detail-panel-actions` | Detail-panel action area |
234
- | `topbar-actions` | Global top app bar (after comms, before utilities) |
235
- | `inbound-call-content` | Incoming-call notification body |
356
+ | Zone | Where it renders |
357
+ | ----------------------- | ---------------------------------------------------------------------------------- |
358
+ | `page-header-actions` | Right side of any page header |
359
+ | `page-header-secondary` | Subtitle row of the page header (badges, status) |
360
+ | `page-content-after` | Below the main page content |
361
+ | `topbar-actions` | Global top app bar (after comms buttons, before utilities) |
362
+ | `table-toolbar` | Toolbar above any DataGrid |
363
+ | `table-filter-bar` | Filter chips inline with the search bar; pass a row predicate via `onFilterChange` |
364
+ | `table-row-actions` | Per-row action column |
365
+ | `form-section-before` | Above a form's field sections (`SidePanel`/`FormPanel`) |
366
+ | `form-section-after` | Below a form's fields, before the action buttons |
367
+ | `inbound-call-content` | Incoming-call notification body |
236
368
 
237
- > **Note**: Check Platform → UI SDK Management → SDK Settings for the current list of available zones and their descriptions.
369
+ > **Note**: Check Platform → UI SDK Management → SDK Settings for the live list of zones (a platform admin can disable individual zones).
238
370
 
239
371
  Custom zones (any string) work for pages that explicitly render `<DynamicExtensionRenderer zone="my-zone" />`.
240
372
 
@@ -250,7 +382,7 @@ Custom zones (any string) work for pages that explicitly render `<DynamicExtensi
250
382
  Hook form:
251
383
 
252
384
  ```tsx
253
- useDynamicExtension(horizonContext.eventBus, "my-app", {
385
+ useDynamicExtension(horizonContext.eventBus, "myApp", {
254
386
  id: "my-app.banner",
255
387
  zone: "page-content-after",
256
388
  routes: [{ pattern: "/manage/:domain/users" }],
@@ -260,11 +392,7 @@ useDynamicExtension(horizonContext.eventBus, "my-app", {
260
392
 
261
393
  #### table-filter-bar zone
262
394
 
263
- Pages that render a filter bar (e.g. active calls, hardphone devices) expose a
264
- `TableFilterBarContext` via `context.pageContext`. Extensions in this zone render
265
- alongside the host's built-in filter chips and can drive row-level filtering by
266
- passing a **predicate function** to `onFilterChange`. The host applies the function
267
- generically — your extension owns the filtering logic, not the host.
395
+ Pages that render a filter bar expose a `TableFilterBarContext` via `context.pageContext`. Extensions in this zone render alongside the host's built-in filter chips and can drive row-level filtering by passing a **predicate function** to `onFilterChange`. The host applies the function generically — your extension owns the filtering logic, not the host.
268
396
 
269
397
  ```tsx
270
398
  import { useState } from "react";
@@ -282,10 +410,9 @@ export function VipFilter({ context }: ExtensionComponentProps) {
282
410
  const next = value === "vip";
283
411
  setActive(next);
284
412
  if (next) {
285
- filterCtx?.onFilterChange((row) => {
286
- const r = row as Record<string, unknown>;
287
- return r["customer-tier"] === "vip";
288
- });
413
+ filterCtx?.onFilterChange(
414
+ (row) => (row as Record<string, unknown>)["customer-tier"] === "vip",
415
+ );
289
416
  } else {
290
417
  filterCtx?.onFilterChange(null); // clear — show all rows
291
418
  }
@@ -302,14 +429,6 @@ export function VipFilter({ context }: ExtensionComponentProps) {
302
429
  />
303
430
  );
304
431
  }
305
-
306
- // Register against any page that exposes a filter bar
307
- sdk.registerDynamicExtension({
308
- id: "my-app.vip-filter",
309
- zone: "table-filter-bar",
310
- routes: [{ pattern: "/manage/:domain/call-logs" }],
311
- component: VipFilter,
312
- });
313
432
  ```
314
433
 
315
434
  `TableFilterBarContext` fields:
@@ -318,21 +437,16 @@ sdk.registerDynamicExtension({
318
437
  | ---------------- | --------------------------------------------------------- | ----------------------------------------------------- |
319
438
  | `onFilterChange` | `(filterFn: ((row: unknown) => boolean) \| null) => void` | Pass a predicate to filter rows; pass `null` to clear |
320
439
 
321
- **Important:** track active/inactive state locally with `useState` in your component
322
- `TableFilterBarContext` does not expose the current filter state back to you.
440
+ **Important:** track active/inactive state locally with `useState` `TableFilterBarContext` does not expose the current filter state back to you.
323
441
 
324
442
  > **React `useState` gotcha** — never pass a filter function directly to `setState`:
325
443
  >
326
444
  > ```ts
327
- > // ❌ React treats functions as lazy initializers, calling fn(prevState)
328
- > setMyFilter(filterFn);
329
- >
330
- > // ✅ Wrap in an arrow function so React stores the function, not its return value
331
- > setMyFilter(() => filterFn);
445
+ > setMyFilter(filterFn); // ❌ React treats functions as lazy initializers, calling fn(prev)
446
+ > setMyFilter(() => filterFn); // ✅ Wrap in an arrow so React stores the function itself
332
447
  > ```
333
448
  >
334
- > This only affects you if you're storing the filter function in your own component state.
335
- > The host DataTable handles this correctly internally.
449
+ > This only affects you if you store the filter function in your own component state. The host DataTable handles this correctly internally.
336
450
 
337
451
  ### Dynamic table columns
338
452
 
@@ -357,149 +471,7 @@ sdk.registerDynamicColumn({
357
471
 
358
472
  Hook: `useDynamicColumn(eventBus, appId, config)`.
359
473
 
360
- ---
361
-
362
- ## Using `horizonContext.ui`
363
-
364
- Don't ship MUI yourself — render Horizon's themed components instead. They inherit the host's Aurora MUI theme, including automatic dark/light mode switching.
365
-
366
- The recommended pattern uses `useHorizonContext()` (see Routes above). The old pattern of accepting `horizonContext` as props still works for extension components:
367
-
368
- ```tsx
369
- // Recommended — page component using the hook (fully reactive theme)
370
- import { useHorizonContext } from "@netsapiens/horizon-sdk";
371
-
372
- export default function MyPage() {
373
- const { ui, user } = useHorizonContext();
374
- const { PageTemplate, DatagridTemplate } = ui.templates;
375
- const { Button, Stack, Typography } = ui;
376
-
377
- return (
378
- <PageTemplate
379
- title="My Page"
380
- breadcrumbs={[{ label: "Apps", url: "/apps" }]}
381
- >
382
- <Stack spacing={2}>
383
- <Typography variant="h5">Hello, {user.displayName}</Typography>
384
- <Button variant="contained">Action</Button>
385
- </Stack>
386
- </PageTemplate>
387
- );
388
- }
389
- ```
390
-
391
- ```tsx
392
- // Legacy — still works for extension components (not full pages)
393
- function MyButton({ context }: ExtensionComponentProps) {
394
- const { Button } = context.ui ?? {};
395
- return <Button variant="contained">Click</Button>;
396
- }
397
- ```
398
-
399
- Available under `horizonContext.ui` (or via `useHorizonContext().ui`):
400
-
401
- - `templates`: `PageTemplate`, `PageTemplateWithExtensions`, `FormTemplate`, `SidePanel`, `FormPanel`, `DatagridTemplate`
402
-
403
- > **`DatagridTemplate` and the MUI X Pro license** — `DatagridTemplate` wraps MUI's `DataGridPro` internally, which is a paid licensed component. The license is held by the Horizon host; **you do not need an MUI X Pro license** and must not import `@mui/x-data-grid-pro` directly in your remote app. Always use `DatagridTemplate` from the SDK.
404
-
405
- - Form: `Button`, `IconButton`, `TextField`, `Select`, `Checkbox`, `Radio`, `RadioGroup`, `Switch`, `ToggleButton`, `ToggleButtonGroup`, `FormLabel`, `FormControlLabel`
406
- - Display: `Typography`, `Chip`, `Avatar`, `Divider`, `Tooltip`, `Icon`
407
- - Layout: `Stack`, `Paper`
408
- - Feedback: `Alert`
409
- - `theme` (`'light' | 'dark'`, reactive via `useHorizonContext()`) and `styles` for derived tokens
410
-
411
- #### IconButton — shorthand-only API
412
-
413
- `IconButton` does **not** accept the standard MUI children pattern. It is a shorthand wrapper that takes the icon name as a string prop:
414
-
415
- ```tsx
416
- // ✅ Correct — pass the Iconify name via the `icon` prop
417
- <IconButton icon="mdi:account" aria-label="Account" />
418
- <IconButton icon="material-symbols:bolt" iconSize={18} size="small" onClick={handleClick} />
419
-
420
- // ❌ Wrong — children are dropped, the button renders empty at 0×0
421
- <IconButton aria-label="Account">
422
- <Icon icon="mdi:account" />
423
- </IconButton>
424
- ```
425
-
426
- The TypeScript types reject the children pattern at compile time. If you ever see a 0×0 button in the DOM, this is the first thing to check.
427
-
428
- Other themed components (`Button`, `Stack`, `Paper`, etc.) follow the standard MUI children pattern.
429
-
430
- #### SidePanel & FormPanel — right-side drawers
431
-
432
- `SidePanel` and `FormPanel` are the shared right-drawer components Horizon uses
433
- for its own add/edit forms and detail panels. Reach for these when building a
434
- right-side drawer so it matches the host exactly and picks up the
435
- `form-section-*` extension zones automatically.
436
-
437
- **`SidePanel`** — the drawer shell (sticky header, scrollable body, optional
438
- sticky footer). Use for detail/view/settings panels:
439
-
440
- ```tsx
441
- const { SidePanel } = ui.templates;
442
-
443
- <SidePanel
444
- open={open}
445
- onClose={() => setOpen(false)}
446
- title="Details"
447
- subtitle="Read-only"
448
- width="lg" // 'sm' | 'md' | 'lg' | 'xl'
449
- footer={<Button onClick={() => setOpen(false)}>Close</Button>}
450
- >
451
- {/* body */}
452
- </SidePanel>;
453
- ```
454
-
455
- **`FormPanel`** — `SidePanel` + a `<form>`, a Submit/Cancel footer, and the
456
- `form-section-before` / `form-section-after` extension zones built in:
457
-
458
- ```tsx
459
- const { FormPanel } = ui.templates;
460
-
461
- <FormPanel
462
- open={open}
463
- onClose={() => setOpen(false)}
464
- title="Add widget"
465
- formType="widget" // identifies the form to extensions
466
- mode="add" // 'add' | 'edit'
467
- formData={values} // live values handed to extensions
468
- onSubmit={(e) => {
469
- e?.preventDefault();
470
- save(values);
471
- }}
472
- submitLabel="Add"
473
- isSubmitting={pending}
474
- error={error}
475
- >
476
- {/* field sections */}
477
- </FormPanel>;
478
- ```
479
-
480
- **Multi-step wizard** — pass `steps` instead of `children`; FormPanel renders the
481
- stepper header and a Back/Next/Submit footer. Each step's optional `validate`
482
- gates forward navigation (return `boolean | Promise<boolean>`):
483
-
484
- ```tsx
485
- <FormPanel
486
- open={open}
487
- onClose={() => setOpen(false)}
488
- title="Setup"
489
- formType="setup"
490
- mode="add"
491
- onSubmit={handleSubmit(onValid)}
492
- isComplete={isValid} // offer Submit before the last step
493
- steps={[
494
- {
495
- label: "Basics",
496
- content: <BasicsStep />,
497
- validate: () => trigger(["name"]),
498
- },
499
- { label: "Options", content: <OptionsStep /> },
500
- ]}
501
- />
502
- ```
474
+ Registered columns **right-align by default** (header and cell) to match Horizon's native data-table columns — you don't need to set `align`. Override per column with `align`/`headerAlign: 'left' | 'center'`.
503
475
 
504
476
  ---
505
477
 
@@ -507,7 +479,7 @@ gates forward navigation (return `boolean | Promise<boolean>`):
507
479
 
508
480
  Dark mode is handled automatically — **you write no theme-switching code for MUI components**.
509
481
 
510
- MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's Aurora ThemeProvider via the shared module singleton and switch the moment the user toggles the theme. For any conditional logic that reads the theme string (custom colors, conditional icons, etc.), use `useTheme()`:
482
+ MUI components (`Button`, `Paper`, `Stack`, `DatagridTemplate`, etc.) inherit the host's ThemeProvider via the shared module singleton and switch the moment the user toggles the theme. For any conditional logic that reads the theme string (custom colors, conditional icons, etc.), use `useTheme()`:
511
483
 
512
484
  ```tsx
513
485
  import { useTheme } from "@netsapiens/horizon-sdk";
@@ -522,19 +494,10 @@ import { useTheme } from "@netsapiens/horizon-sdk";
522
494
 
523
495
  ```tsx
524
496
  // Page component
525
- export default function MyPage() {
526
- const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
527
- return (
528
- <div style={{ background: theme === "dark" ? "#1B2124" : "#fff" }}>...</div>
529
- );
530
- }
497
+ const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
531
498
 
532
499
  // Extension component
533
- export default function MyButton({ context }: ExtensionComponentProps) {
534
- const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
535
- const { Button } = context.ui ?? {};
536
- return <Button>{theme === "dark" ? "🌙" : "☀️"} Export</Button>;
537
- }
500
+ const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
538
501
  ```
539
502
 
540
503
  > Do not subscribe to `theme:changed` on the event bus manually. `useTheme()` handles the subscription and cleanup internally.
@@ -546,9 +509,9 @@ export default function MyButton({ context }: ExtensionComponentProps) {
546
509
  ```tsx
547
510
  const { api, user } = horizonContext;
548
511
  const devices = await api.get(
549
- `/domains/${user.domain}/users/${user.displayName}/devices`,
512
+ `/domains/${user.domain}/users/${user.extension}/devices`,
550
513
  );
551
- await api.post(`/domains/${user.domain}/users/${user.displayName}/contacts`, {
514
+ await api.post(`/domains/${user.domain}/users/${user.extension}/contacts`, {
552
515
  "name-first-name": "John",
553
516
  });
554
517
  ```
@@ -557,21 +520,54 @@ All calls run through Horizon's audited proxy — credentials never reach the re
557
520
 
558
521
  ---
559
522
 
523
+ ## Remote authentication
524
+
525
+ When your app needs to call **your own backend** (or a third-party vendor) on behalf of the signed-in user, have the host broker a trusted identity handshake. This lets your server verify that a request genuinely represents the current Horizon user — without your app ever handling Horizon credentials.
526
+
527
+ ```tsx
528
+ const { auth } = horizonContext;
529
+
530
+ const token = await auth.requestRemoteAuth({
531
+ vendorId: "my-backend", // your system/vendor id
532
+ callbackUrl: "https://api.example.com/horizon/callback", // your backend webhook
533
+ scopes: ["contacts:read"], // optional
534
+ });
535
+ // token: { vendorId, accessToken, tokenType?, expiresAt?, refreshToken?, metadata? }
536
+ ```
537
+
538
+ **Flow:**
539
+
540
+ 1. Your app calls `auth.requestRemoteAuth(...)`.
541
+ 2. The host validates the request — the app must have remote auth enabled and the `callbackUrl` host must be on the app's allowed-hostnames list — then brokers the handshake and delivers a signed auth code to your `callbackUrl`.
542
+ 3. **Your backend verifies authenticity** by checking the HMAC signature header (`X-NS-Signature`) against your app's shared callback secret. A valid signature proves the request came from Horizon, so you can trust the asserted user identity, then issue your own token.
543
+ 4. The promise resolves with a `RemoteAuthResponse` (your token), cached for the session; it rejects with a `RemoteAuthError` on failure or timeout.
544
+
545
+ Retrieve or clear a cached token later:
546
+
547
+ ```tsx
548
+ const cached = auth.getRemoteAuthToken("my-backend"); // RemoteAuthResponse | null
549
+ auth.clearRemoteAuthToken("my-backend"); // sign out of the vendor
550
+ ```
551
+
552
+ **Admin setup (per app, in Registered Apps):** enable remote auth, list the allowed callback hostname(s), and set the callback signing secret your backend uses to verify the `X-NS-Signature`.
553
+
554
+ ---
555
+
560
556
  ## Event bus
561
557
 
562
558
  For cross-app messages and host events:
563
559
 
564
560
  ```tsx
565
561
  useEffect(() => {
566
- const handler = (data: unknown) => console.log("theme changed", data);
567
- horizonContext.eventBus.on("theme:changed", handler);
568
- return () => horizonContext.eventBus.off("theme:changed", handler);
562
+ const handler = (data: unknown) => console.log("call event", data);
563
+ horizonContext.eventBus.on("call-event", handler);
564
+ return () => horizonContext.eventBus.off("call-event", handler);
569
565
  }, []);
570
566
  ```
571
567
 
572
568
  Common host-emitted events: `theme:changed`, `call-event`, `routes:updated`. Apps can emit any custom event under their own namespace (`my-app:something`).
573
569
 
574
- > **You do not need to subscribe to `theme:changed` anywhere.** Use `useTheme()` from the SDK it handles the subscription internally for both page components and extension components. See the Dark mode section.
570
+ > There is **no generic `useEvent` hook** subscribe directly with `context.eventBus.on(...)` / `.off(...)` and clean up in your effect. The SDK only wraps the specific events it manages for you: `useTheme()` (theme changes), `useLocale()` (locale changes), and `useSidePanel()`. In particular, **do not** subscribe to `theme:changed` yourself — use `useTheme()`.
575
571
 
576
572
  ---
577
573
 
@@ -586,7 +582,7 @@ Your app is delivered as a static bundle served over HTTPS. Before you can regis
586
582
  **CORS** — The Horizon host makes a cross-origin request to fetch your `remoteEntry.js`. Your server must respond with:
587
583
 
588
584
  ```
589
- Access-Control-Allow-Origin: https://your-horizon-instance.com
585
+ Access-Control-Allow-Origin: https://your-horizon-instance.fqdn
590
586
  ```
591
587
 
592
588
  During local development the webpack dev server sets `Access-Control-Allow-Origin: *` (any origin) so you can test against any Horizon instance. Tighten this to the specific Horizon domain before deploying to production — `*` in production means any website could load your bundle.
@@ -622,7 +618,7 @@ module.exports = (_env, argv) => ({
622
618
  loglevel: { singleton: true, requiredVersion: "^1.9.0" },
623
619
  "@netsapiens/horizon-sdk": {
624
620
  singleton: true,
625
- requiredVersion: "^1.0.0",
621
+ requiredVersion: "^0.1.0", // match the @netsapiens/horizon-sdk version you installed
626
622
  },
627
623
 
628
624
  // Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
@@ -630,7 +626,7 @@ module.exports = (_env, argv) => ({
630
626
  // so declaring it as a singleton causes an "Unsatisfied version" crash.
631
627
  //
632
628
  // Instead, consume all MUI components via horizonContext.ui — this is
633
- // what gives your components the Aurora theme and dark mode automatically.
629
+ // what gives your components the theme and dark mode automatically.
634
630
  },
635
631
  }),
636
632
  ],
@@ -655,7 +651,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
655
651
  "description": "Short description shown in the app list",
656
652
  "version": "1.0.0",
657
653
  "remote_entry_url": "https://cdn.example.com/my-app/remoteEntry.js",
658
- "module_scope": "myApp",
654
+ "webpack_module": "myApp",
659
655
  "author": "Acme Corp",
660
656
  "enabled": true
661
657
  }'
@@ -675,7 +671,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
675
671
 
676
672
  ### Extension App ID
677
673
 
678
- The **Extension App ID** (stored as `module_scope`) is the single most important field to get right. It must **exactly match** the `name` value in your webpack `ModuleFederationPlugin` config:
674
+ The **Extension App ID** (the API field `webpack_module`) is the single most important field to get right. It must **exactly match** the `name` value in your webpack `ModuleFederationPlugin` config:
679
675
 
680
676
  ```js
681
677
  // webpack.config.js
@@ -687,16 +683,18 @@ new ModuleFederationPlugin({
687
683
 
688
684
  ```json
689
685
  // Registration
690
- { "module_scope": "myUcaasApp" }
686
+ { "webpack_module": "myUcaasApp" }
691
687
  ```
692
688
 
693
689
  **Why it matters:** At runtime, the platform loads your bundle and calls `window['myUcaasApp'].init(...)` to initialise the federation container. If the name doesn't match exactly — including case — the container won't be found and your app silently fails to load with no visible error.
694
690
 
691
+ **One identifier, everywhere.** `webpack_module` is the only id you maintain. You pass the same value to `useRemoteApp(horizonContext, "myUcaasApp")`, and the SDK, host, and API all derive the kebab-case registry `id` (`my-ucaas-app`) from it the same way — that derived id is the registration's primary key, the `/ui-extensions/{id}` path, and the attribution id for your extensions. (Need it explicitly? `import { deriveAppId } from "@netsapiens/horizon-sdk"`.)
692
+
695
693
  **Rules:**
696
694
 
697
- - Must be unique across every app registered on the platform duplicate names cause both apps to fail
698
- - Use camelCase (e.g. `myUcaasApp`, not `my-ucaas-app` or `MyUcaasApp`)
699
- - No spaces or special characters
695
+ - Must be a **valid JavaScript identifier** — letters, digits, `_`, `$`; no dashes or spaces, can't start with a digit. webpack's default container library declares it as a `var`, so a dash (e.g. `my-app`) fails the build with _"name must be a valid identifier."_ camelCase is the convention (e.g. `myUcaasApp`, not `my-ucaas-app` or `MyUcaasApp`).
696
+ - Must be **unique platform-wide**. The API enforces this with a database unique constraint and returns **HTTP `409 Conflict`** if the `webpack_module` is already taken — on both register and update. Pick a distinctive, namespaced value (e.g. `acmeCrmSync`, not `crm`).
697
+ - Immutable once registered it's baked into your built `remoteEntry.js` as the container global, so changing it means rebuilding and re-registering.
700
698
 
701
699
  ### Domain whitelist
702
700
 
@@ -713,15 +711,11 @@ If the domain is not approved, your app will silently fail to load. Check the br
713
711
 
714
712
  After registering and refreshing the browser:
715
713
 
716
- 1. **Open DevTools → Console** and look for log lines starting with `[Demo App]` or your app name — your `App.tsx` runs on page load and any `console.log` calls appear here
717
-
718
- 2. **Check the network tab** — filter by your domain and look for `remoteEntry.js`. A `200` response confirms the bundle was fetched. A `CORS error` or `net::ERR_*` means the domain isn't reachable or CORS headers are missing
719
-
714
+ 1. **Open DevTools → Console** and look for log lines from your app — your `App.tsx` runs on page load and any `console.log` calls appear here
715
+ 2. **Check the network tab** — filter by your domain and look for `remoteEntry.js`. A `200` confirms the bundle was fetched; a `CORS error` or `net::ERR_*` means the domain isn't reachable or CORS headers are missing
720
716
  3. **Look for your registered routes** — if you called `sdk.registerRoute`, your page should appear in the navigation sidebar immediately after the app loads
721
-
722
717
  4. **Look for your extensions** — visit the route pattern your extension targets (e.g. `/manage/call-logs`) and confirm the injected component appears
723
-
724
- 5. **Check the Registered Apps page** (Platform → UI SDK Management → Registered Apps) — the **Extension Zones** column will show which zones your app has active extensions registered in once it has loaded
718
+ 5. **Check the Registered Apps page** (Platform → UI SDK Management → Registered Apps) — the **Extension Zones** column shows which zones your app has active extensions in once it has loaded
725
719
 
726
720
  If nothing appears after a full page refresh:
727
721
 
@@ -729,6 +723,17 @@ If nothing appears after a full page refresh:
729
723
  - Confirm your CDN domain is approved (see above)
730
724
  - Check for JavaScript errors in the console — a crash in your `App.tsx` before `sdk.registerRoute` is called means nothing gets registered
731
725
 
726
+ **Live QA toggle:** from the browser console, `window.__horizonSDKDebug__` exposes session-local debug helpers — handy for isolating whether an issue comes from a remote app:
727
+
728
+ ```js
729
+ window.__horizonSDKDebug__.help(); // list all commands
730
+ window.__horizonSDKDebug__.disableExtensionApps(); // reload with ALL remote apps off (this session only)
731
+ window.__horizonSDKDebug__.enableExtensionApps(); // re-enable and reload
732
+ window.__horizonSDKDebug__.enable(); // turn on verbose SDK logging
733
+ ```
734
+
735
+ The disable state is session-local (persisted in `localStorage`), so it only affects your own browser.
736
+
732
737
  ---
733
738
 
734
739
  ## After registration
@@ -737,7 +742,7 @@ If nothing appears after a full page refresh:
737
742
 
738
743
  **Per-session load:** Your app's `App.tsx` mounts once per browser session inside a hidden container. It runs for the lifetime of the session, keeping all registrations active. Navigating between pages does not reload the app.
739
744
 
740
- **Enabling and disabling:** Toggling the **Enabled** switch takes effect immediately for new sessions — the running app list is refreshed in the background. Users in active sessions will see the change on their next page load.
745
+ **Enabling and disabling:** Toggling the **Enabled** switch takes effect immediately for new sessions — the running app list is refreshed in the background. Users in active sessions see the change on their next page load.
741
746
 
742
747
  ---
743
748
 
@@ -751,6 +756,16 @@ If you use version-specific URLs (e.g. `cdn.example.com/my-app/v1.2.0/remoteEntr
751
756
 
752
757
  Active sessions continue using the version that loaded when they started. Users see the new version on their next page refresh.
753
758
 
759
+ > **Integrity (SRI) checking — opt-in.** You may register an `integrity_hash` (Subresource Integrity hash of your `remoteEntry.js`, encoded as `sha384-<base64>`). When present, the host verifies the fetched bundle against it and refuses to load on mismatch; when absent, the check is skipped. It is opt-in today and not yet enforced by the platform.
760
+ >
761
+ > Generate the hash from your **build** so it always matches the emitted bundle, e.g.:
762
+ >
763
+ > ```bash
764
+ > echo "sha384-$(openssl dgst -sha384 -binary dist/remoteEntry.js | openssl base64 -A)"
765
+ > ```
766
+ >
767
+ > Submit that value as `integrity_hash` on register/update. When you redeploy, **regenerate it for the new bundle and update the registration** — a stale hash makes the app fail the integrity check and refuse to load.
768
+
754
769
  ### Disable vs. Delete
755
770
 
756
771
  | Action | Effect | Reversible? |
@@ -788,32 +803,15 @@ Codes: `PERMISSION_DENIED`, `RATE_LIMIT_EXCEEDED`, `API_ERROR`, `NETWORK_ERROR`,
788
803
 
789
804
  ---
790
805
 
791
- ## Scaffolding a new app with Claude Code
792
-
793
- Run the slash command:
794
-
795
- ```
796
- /create-horizon-app
797
- ```
798
-
799
- (see `.claude/skills/create-horizon-app.md` for the full prompt.)
800
-
801
- Or paste this into a Claude Code session:
802
-
803
- > Scaffold a new remote app for NetSapiens Horizon called `<app-name>` with Extension App ID `<extensionAppId>` on port `<port>`. It should:
804
- >
805
- > - Use webpack Module Federation, expose `./App`, and share react/react-dom/loglevel/@netsapiens/horizon-sdk as singletons
806
- > - Have an `App.tsx` that uses `useRemoteApp(horizonContext, '<app-id>')` and registers one route at `/home/<app-id>` plus one dynamic extension on `page-header-actions` for `/manage/*/users`
807
- > - Use `HorizonContextProvider` + `useHorizonContext()` for all page components so dark mode works automatically
808
- > - Do NOT share `@mui/material`, `@mui/x-data-grid-pro`, `@emotion/*` in webpack — these are not provided by the host's federation loader
809
- > - Render all UI through `horizonContext.ui` to get themed components
810
- > - Include a README with run + register instructions
811
-
812
- For one-off tasks inside an existing remote app, prompts like these work well:
806
+ ## Migration
813
807
 
814
- > Add a dynamic table column to the call-logs grid that shows call priority. Use `sdk.registerDynamicColumn` with zone `call-logs-columns` and route pattern `/manage/*/call-logs`. The column should sort and filter by computed priority (`high`/`medium`/`low`).
808
+ The SDK is pre-release (`0.x`) and not yet published, so no released version is affected but if you built against an earlier working copy, note these breaking changes:
815
809
 
816
- > Add an extension to `inbound-call-content` zone that fetches caller info from our CRM API and shows name + last contact date. Subscribe to `call-event` on the event bus and enrich the call data through `horizonContext.api`.
810
+ - **Registration field renamed: `module_scope` `webpack_module`.** The `/ui-extensions` API request and response now use `webpack_module`. Re-register or update your app with the new field name; the value itself (your `ModuleFederationPlugin` `name`) is unchanged. See [Extension App ID](#extension-app-id).
811
+ - **`webpack_module` must be unique platform-wide.** Registration now fails with **HTTP `409 Conflict`** if the value is already taken (register and update). Choose a distinctive, namespaced id.
812
+ - **React 19 only.** The `react` / `react-dom` peer dependency is now `^19` (was `^18 || ^19`). These are greenfield apps — target React 19.
813
+ - **Anchor constants removed.** `MANAGE_ANCHORS` / `PLATFORM_ANCHORS` / `APPS_ANCHORS` / `MY_ACCOUNT_ANCHORS` / `ANCHORS` and the `AnchorId` type are gone. Pass placement targets as plain strings (e.g. `placement: { after: "contacts" }`) — the host resolves them with fuzzy matching. See [Menu placement](#menu-placement).
814
+ - **`integrity_hash` (opt-in, additive).** You may now submit an SRI hash (`sha384-<base64>`) on registration. See [Updating a deployed app](#updating-a-deployed-app).
817
815
 
818
816
  ---
819
817