@netsapiens/horizon-sdk 0.1.4 → 0.1.6

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
 
@@ -75,21 +235,6 @@ export default function MyButton({ context }: ExtensionComponentProps) {
75
235
 
76
236
  ---
77
237
 
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
238
  ## Registration APIs
94
239
 
95
240
  ### Routes — full pages added to Horizon
@@ -119,7 +264,7 @@ const MySettingsRoute = useMemo(
119
264
 
120
265
  sdk.registerRoute({
121
266
  id: "my-app.settings",
122
- parentPath: "/home", // attach under Apps menu
267
+ parentPath: "/home", // attach under the My Account menu
123
268
  path: "my-settings", // → /home/my-settings
124
269
  label: "My Settings",
125
270
  icon: "mdi:cog",
@@ -128,39 +273,14 @@ sdk.registerRoute({
128
273
  });
129
274
  ```
130
275
 
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:
276
+ Inside the page component, read context with `useHorizonContext()`:
157
277
 
158
278
  ```tsx
159
279
  // src/pages/MySettingsPage.tsx
160
280
  import { useHorizonContext } from "@netsapiens/horizon-sdk";
161
281
 
162
282
  export default function MySettingsPage() {
163
- const { ui, user, navigate, theme } = useHorizonContext();
283
+ const { ui, user, navigate } = useHorizonContext();
164
284
  const { PageTemplate } = ui.templates;
165
285
  const { Button, Stack, Typography } = ui;
166
286
 
@@ -186,7 +306,7 @@ The `useRoute` hook handles register + unregister automatically:
186
306
  useRoute(horizonContext.eventBus, 'my-app', { id: '...', /* ... */, component: MySettingsRoute });
187
307
  ```
188
308
 
189
- If your route component lives in a separate exposed module, use `useRouteFromModule`:
309
+ 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
310
 
191
311
  ```tsx
192
312
  const { loading, error } = useRouteFromModule(
@@ -198,10 +318,21 @@ const { loading, error } = useRouteFromModule(
198
318
  path: "my-settings",
199
319
  label: "My Settings",
200
320
  },
201
- { scope: "myApp", module: "./SettingsPage" },
321
+ { scope: "myApp", module: "./SettingsPage" }, // an exposed module in YOUR webpack config
202
322
  );
203
323
  ```
204
324
 
325
+ #### Menu placement
326
+
327
+ Use `placement` to control where your route appears in the menu, relative to existing menu items:
328
+
329
+ - `{ after: 'anchor-id' }` — Place immediately after the specified item
330
+ - `{ before: 'anchor-id' }` — Place immediately before the specified item
331
+ - `{ first: true }` — Force to the start of the menu
332
+ - `{ last: true }` — Force to the end of the menu (default if no placement specified)
333
+
334
+ 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.
335
+
205
336
  ### Dynamic extensions — inject UI into host pages
206
337
 
207
338
  Extensions render at named **zones** on routes that match a **pattern**. You don't need the host page to opt in.
@@ -218,23 +349,22 @@ sdk.registerDynamicExtension({
218
349
  });
219
350
  ```
220
351
 
221
- **Available zones:**
352
+ **Available zones** (the zones the host mounts today):
222
353
 
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 |
354
+ | Zone | Where it renders |
355
+ | ----------------------- | ---------------------------------------------------------------------------------- |
356
+ | `page-header-actions` | Right side of any page header |
357
+ | `page-header-secondary` | Subtitle row of the page header (badges, status) |
358
+ | `page-content-after` | Below the main page content |
359
+ | `topbar-actions` | Global top app bar (after comms buttons, before utilities) |
360
+ | `table-toolbar` | Toolbar above any DataGrid |
361
+ | `table-filter-bar` | Filter chips inline with the search bar; pass a row predicate via `onFilterChange` |
362
+ | `table-row-actions` | Per-row action column |
363
+ | `form-section-before` | Above a form's field sections (`SidePanel`/`FormPanel`) |
364
+ | `form-section-after` | Below a form's fields, before the action buttons |
365
+ | `inbound-call-content` | Incoming-call notification body |
236
366
 
237
- > **Note**: Check Platform → UI SDK Management → SDK Settings for the current list of available zones and their descriptions.
367
+ > **Note**: Check Platform → UI SDK Management → SDK Settings for the live list of zones (a platform admin can disable individual zones).
238
368
 
239
369
  Custom zones (any string) work for pages that explicitly render `<DynamicExtensionRenderer zone="my-zone" />`.
240
370
 
@@ -260,11 +390,7 @@ useDynamicExtension(horizonContext.eventBus, "my-app", {
260
390
 
261
391
  #### table-filter-bar zone
262
392
 
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.
393
+ 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
394
 
269
395
  ```tsx
270
396
  import { useState } from "react";
@@ -282,10 +408,9 @@ export function VipFilter({ context }: ExtensionComponentProps) {
282
408
  const next = value === "vip";
283
409
  setActive(next);
284
410
  if (next) {
285
- filterCtx?.onFilterChange((row) => {
286
- const r = row as Record<string, unknown>;
287
- return r["customer-tier"] === "vip";
288
- });
411
+ filterCtx?.onFilterChange(
412
+ (row) => (row as Record<string, unknown>)["customer-tier"] === "vip",
413
+ );
289
414
  } else {
290
415
  filterCtx?.onFilterChange(null); // clear — show all rows
291
416
  }
@@ -302,14 +427,6 @@ export function VipFilter({ context }: ExtensionComponentProps) {
302
427
  />
303
428
  );
304
429
  }
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
430
  ```
314
431
 
315
432
  `TableFilterBarContext` fields:
@@ -318,21 +435,16 @@ sdk.registerDynamicExtension({
318
435
  | ---------------- | --------------------------------------------------------- | ----------------------------------------------------- |
319
436
  | `onFilterChange` | `(filterFn: ((row: unknown) => boolean) \| null) => void` | Pass a predicate to filter rows; pass `null` to clear |
320
437
 
321
- **Important:** track active/inactive state locally with `useState` in your component
322
- `TableFilterBarContext` does not expose the current filter state back to you.
438
+ **Important:** track active/inactive state locally with `useState` `TableFilterBarContext` does not expose the current filter state back to you.
323
439
 
324
440
  > **React `useState` gotcha** — never pass a filter function directly to `setState`:
325
441
  >
326
442
  > ```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);
443
+ > setMyFilter(filterFn); // ❌ React treats functions as lazy initializers, calling fn(prev)
444
+ > setMyFilter(() => filterFn); // ✅ Wrap in an arrow so React stores the function itself
332
445
  > ```
333
446
  >
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.
447
+ > This only affects you if you store the filter function in your own component state. The host DataTable handles this correctly internally.
336
448
 
337
449
  ### Dynamic table columns
338
450
 
@@ -357,149 +469,7 @@ sdk.registerDynamicColumn({
357
469
 
358
470
  Hook: `useDynamicColumn(eventBus, appId, config)`.
359
471
 
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
- ```
472
+ 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
473
 
504
474
  ---
505
475
 
@@ -507,7 +477,7 @@ gates forward navigation (return `boolean | Promise<boolean>`):
507
477
 
508
478
  Dark mode is handled automatically — **you write no theme-switching code for MUI components**.
509
479
 
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()`:
480
+ 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
481
 
512
482
  ```tsx
513
483
  import { useTheme } from "@netsapiens/horizon-sdk";
@@ -522,19 +492,10 @@ import { useTheme } from "@netsapiens/horizon-sdk";
522
492
 
523
493
  ```tsx
524
494
  // 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
- }
495
+ const { theme } = useTheme(); // no argument needed inside HorizonContextProvider
531
496
 
532
497
  // 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
- }
498
+ const { theme } = useTheme(context.eventBus); // pass eventBus for extensions
538
499
  ```
539
500
 
540
501
  > Do not subscribe to `theme:changed` on the event bus manually. `useTheme()` handles the subscription and cleanup internally.
@@ -546,9 +507,9 @@ export default function MyButton({ context }: ExtensionComponentProps) {
546
507
  ```tsx
547
508
  const { api, user } = horizonContext;
548
509
  const devices = await api.get(
549
- `/domains/${user.domain}/users/${user.displayName}/devices`,
510
+ `/domains/${user.domain}/users/${user.extension}/devices`,
550
511
  );
551
- await api.post(`/domains/${user.domain}/users/${user.displayName}/contacts`, {
512
+ await api.post(`/domains/${user.domain}/users/${user.extension}/contacts`, {
552
513
  "name-first-name": "John",
553
514
  });
554
515
  ```
@@ -557,21 +518,54 @@ All calls run through Horizon's audited proxy — credentials never reach the re
557
518
 
558
519
  ---
559
520
 
521
+ ## Remote authentication
522
+
523
+ 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.
524
+
525
+ ```tsx
526
+ const { auth } = horizonContext;
527
+
528
+ const token = await auth.requestRemoteAuth({
529
+ vendorId: "my-backend", // your system/vendor id
530
+ callbackUrl: "https://api.example.com/horizon/callback", // your backend webhook
531
+ scopes: ["contacts:read"], // optional
532
+ });
533
+ // token: { vendorId, accessToken, tokenType?, expiresAt?, refreshToken?, metadata? }
534
+ ```
535
+
536
+ **Flow:**
537
+
538
+ 1. Your app calls `auth.requestRemoteAuth(...)`.
539
+ 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`.
540
+ 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.
541
+ 4. The promise resolves with a `RemoteAuthResponse` (your token), cached for the session; it rejects with a `RemoteAuthError` on failure or timeout.
542
+
543
+ Retrieve or clear a cached token later:
544
+
545
+ ```tsx
546
+ const cached = auth.getRemoteAuthToken("my-backend"); // RemoteAuthResponse | null
547
+ auth.clearRemoteAuthToken("my-backend"); // sign out of the vendor
548
+ ```
549
+
550
+ **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`.
551
+
552
+ ---
553
+
560
554
  ## Event bus
561
555
 
562
556
  For cross-app messages and host events:
563
557
 
564
558
  ```tsx
565
559
  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);
560
+ const handler = (data: unknown) => console.log("call event", data);
561
+ horizonContext.eventBus.on("call-event", handler);
562
+ return () => horizonContext.eventBus.off("call-event", handler);
569
563
  }, []);
570
564
  ```
571
565
 
572
566
  Common host-emitted events: `theme:changed`, `call-event`, `routes:updated`. Apps can emit any custom event under their own namespace (`my-app:something`).
573
567
 
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.
568
+ > 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
569
 
576
570
  ---
577
571
 
@@ -586,7 +580,7 @@ Your app is delivered as a static bundle served over HTTPS. Before you can regis
586
580
  **CORS** — The Horizon host makes a cross-origin request to fetch your `remoteEntry.js`. Your server must respond with:
587
581
 
588
582
  ```
589
- Access-Control-Allow-Origin: https://your-horizon-instance.com
583
+ Access-Control-Allow-Origin: https://your-horizon-instance.fqdn
590
584
  ```
591
585
 
592
586
  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 +616,7 @@ module.exports = (_env, argv) => ({
622
616
  loglevel: { singleton: true, requiredVersion: "^1.9.0" },
623
617
  "@netsapiens/horizon-sdk": {
624
618
  singleton: true,
625
- requiredVersion: "^1.0.0",
619
+ requiredVersion: "^0.1.0", // match the @netsapiens/horizon-sdk version you installed
626
620
  },
627
621
 
628
622
  // Do NOT add @mui/material, @emotion/*, or @mui/x-data-grid-pro here.
@@ -630,7 +624,7 @@ module.exports = (_env, argv) => ({
630
624
  // so declaring it as a singleton causes an "Unsatisfied version" crash.
631
625
  //
632
626
  // Instead, consume all MUI components via horizonContext.ui — this is
633
- // what gives your components the Aurora theme and dark mode automatically.
627
+ // what gives your components the theme and dark mode automatically.
634
628
  },
635
629
  }),
636
630
  ],
@@ -655,7 +649,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
655
649
  "description": "Short description shown in the app list",
656
650
  "version": "1.0.0",
657
651
  "remote_entry_url": "https://cdn.example.com/my-app/remoteEntry.js",
658
- "module_scope": "myApp",
652
+ "webpack_module": "myApp",
659
653
  "author": "Acme Corp",
660
654
  "enabled": true
661
655
  }'
@@ -675,7 +669,7 @@ curl -X POST "https://your-horizon-instance.com/ns-api/v2/ui-extensions" \
675
669
 
676
670
  ### Extension App ID
677
671
 
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:
672
+ 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
673
 
680
674
  ```js
681
675
  // webpack.config.js
@@ -687,14 +681,14 @@ new ModuleFederationPlugin({
687
681
 
688
682
  ```json
689
683
  // Registration
690
- { "module_scope": "myUcaasApp" }
684
+ { "webpack_module": "myUcaasApp" }
691
685
  ```
692
686
 
693
687
  **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
688
 
695
689
  **Rules:**
696
690
 
697
- - Must be unique across every app registered on the platform duplicate names cause both apps to fail
691
+ - 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`).
698
692
  - Use camelCase (e.g. `myUcaasApp`, not `my-ucaas-app` or `MyUcaasApp`)
699
693
  - No spaces or special characters
700
694
 
@@ -713,15 +707,11 @@ If the domain is not approved, your app will silently fail to load. Check the br
713
707
 
714
708
  After registering and refreshing the browser:
715
709
 
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
-
710
+ 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
711
+ 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
712
  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
713
  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
714
+ 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
715
 
726
716
  If nothing appears after a full page refresh:
727
717
 
@@ -729,6 +719,17 @@ If nothing appears after a full page refresh:
729
719
  - Confirm your CDN domain is approved (see above)
730
720
  - Check for JavaScript errors in the console — a crash in your `App.tsx` before `sdk.registerRoute` is called means nothing gets registered
731
721
 
722
+ **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:
723
+
724
+ ```js
725
+ window.__horizonSDKDebug__.help(); // list all commands
726
+ window.__horizonSDKDebug__.disableExtensionApps(); // reload with ALL remote apps off (this session only)
727
+ window.__horizonSDKDebug__.enableExtensionApps(); // re-enable and reload
728
+ window.__horizonSDKDebug__.enable(); // turn on verbose SDK logging
729
+ ```
730
+
731
+ The disable state is session-local (persisted in `localStorage`), so it only affects your own browser.
732
+
732
733
  ---
733
734
 
734
735
  ## After registration
@@ -737,7 +738,7 @@ If nothing appears after a full page refresh:
737
738
 
738
739
  **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
740
 
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.
741
+ **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
742
 
742
743
  ---
743
744
 
@@ -751,6 +752,16 @@ If you use version-specific URLs (e.g. `cdn.example.com/my-app/v1.2.0/remoteEntr
751
752
 
752
753
  Active sessions continue using the version that loaded when they started. Users see the new version on their next page refresh.
753
754
 
755
+ > **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.
756
+ >
757
+ > Generate the hash from your **build** so it always matches the emitted bundle, e.g.:
758
+ >
759
+ > ```bash
760
+ > echo "sha384-$(openssl dgst -sha384 -binary dist/remoteEntry.js | openssl base64 -A)"
761
+ > ```
762
+ >
763
+ > 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.
764
+
754
765
  ### Disable vs. Delete
755
766
 
756
767
  | Action | Effect | Reversible? |
@@ -788,32 +799,15 @@ Codes: `PERMISSION_DENIED`, `RATE_LIMIT_EXCEEDED`, `API_ERROR`, `NETWORK_ERROR`,
788
799
 
789
800
  ---
790
801
 
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:
802
+ ## Migration
813
803
 
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`).
804
+ 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
805
 
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`.
806
+ - **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).
807
+ - **`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.
808
+ - **React 19 only.** The `react` / `react-dom` peer dependency is now `^19` (was `^18 || ^19`). These are greenfield apps — target React 19.
809
+ - **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).
810
+ - **`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
811
 
818
812
  ---
819
813