@antelopejs/interface-dms 0.7.4 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +176 -0
- package/dist/auth/index.d.ts +3 -3
- package/dist/auth/index.js +11 -9
- package/dist/auth/index.js.map +1 -1
- package/dist/auth/internal/jwt-auth-decorator.d.ts +20 -0
- package/dist/auth/internal/jwt-auth-decorator.js +51 -0
- package/dist/auth/internal/jwt-auth-decorator.js.map +1 -0
- package/dist/auth/internal/request-authenticators.d.ts +12 -0
- package/dist/auth/internal/request-authenticators.js +22 -0
- package/dist/auth/internal/request-authenticators.js.map +1 -1
- package/dist/base/activity-feed.d.ts +6 -6
- package/dist/base/block-registry/types.d.ts +16 -0
- package/dist/base/block-types.d.ts +1 -0
- package/dist/base/block-types.js +1 -0
- package/dist/base/block-types.js.map +1 -1
- package/dist/base/chart-card.d.ts +15 -0
- package/dist/base/chart-card.js +4 -0
- package/dist/base/chart-card.js.map +1 -1
- package/dist/base/chart-schemas.d.ts +26 -26
- package/dist/base/code-block.d.ts +3 -3
- package/dist/base/data-types/compare-types.js +19 -13
- package/dist/base/data-types/compare-types.js.map +1 -1
- package/dist/base/data-types/core.d.ts +11 -0
- package/dist/base/data-types/core.js +14 -1
- package/dist/base/data-types/core.js.map +1 -1
- package/dist/base/data-types/default-types.d.ts +112 -2
- package/dist/base/data-types/default-types.js +90 -19
- package/dist/base/data-types/default-types.js.map +1 -1
- package/dist/base/data-types/field-types.d.ts +10 -4
- package/dist/base/data-types/field-types.js +12 -2
- package/dist/base/data-types/field-types.js.map +1 -1
- package/dist/base/data-types/internal/compare-types.d.ts +3 -0
- package/dist/base/data-types/internal/compare-types.js +7 -0
- package/dist/base/data-types/internal/compare-types.js.map +1 -1
- package/dist/base/empty-state.d.ts +4 -4
- package/dist/base/form-block-schema.d.ts +59 -3
- package/dist/base/form-block-schema.js +12 -2
- package/dist/base/form-block-schema.js.map +1 -1
- package/dist/base/form-schema.d.ts +57 -5
- package/dist/base/form-schema.js +22 -0
- package/dist/base/form-schema.js.map +1 -1
- package/dist/base/form-types.d.ts +82 -13
- package/dist/base/form-types.js +12 -4
- package/dist/base/form-types.js.map +1 -1
- package/dist/base/guard.d.ts +6 -6
- package/dist/base/helpers/archive-mode-helpers.js +6 -2
- package/dist/base/helpers/archive-mode-helpers.js.map +1 -1
- package/dist/base/index.d.ts +1 -0
- package/dist/base/index.js +1 -0
- package/dist/base/index.js.map +1 -1
- package/dist/base/internal/form-schema.js +5 -0
- package/dist/base/internal/form-schema.js.map +1 -1
- package/dist/base/internal/layouts.d.ts +15 -3
- package/dist/base/key-value-list.d.ts +12 -9
- package/dist/base/key-value-list.js +1 -1
- package/dist/base/key-value-list.js.map +1 -1
- package/dist/base/kpi-card.d.ts +39 -5
- package/dist/base/kpi-card.js +18 -6
- package/dist/base/kpi-card.js.map +1 -1
- package/dist/base/layouts.d.ts +24 -1
- package/dist/base/layouts.js.map +1 -1
- package/dist/base/live-toggle.d.ts +11 -0
- package/dist/base/live-toggle.js +8 -1
- package/dist/base/live-toggle.js.map +1 -1
- package/dist/base/page-toolbar.d.ts +158 -0
- package/dist/base/page-toolbar.js +198 -0
- package/dist/base/page-toolbar.js.map +1 -0
- package/dist/base/period-selector.d.ts +9 -2
- package/dist/base/period-selector.js.map +1 -1
- package/dist/base/placeholder.d.ts +2 -2
- package/dist/base/section.d.ts +2 -2
- package/dist/base/stack.d.ts +2 -2
- package/dist/base/stat-group.d.ts +18 -6
- package/dist/base/stat-group.js +9 -0
- package/dist/base/stat-group.js.map +1 -1
- package/dist/base/table-view/column-display.d.ts +9 -5
- package/dist/base/table-view/column-display.js +1 -1
- package/dist/base/table-view/column-display.js.map +1 -1
- package/dist/base/table-view/data-functions.d.ts +26 -7
- package/dist/base/table-view/data-functions.js +10 -6
- package/dist/base/table-view/data-functions.js.map +1 -1
- package/dist/base/table-view/factory.js +24 -13
- package/dist/base/table-view/factory.js.map +1 -1
- package/dist/base/table-view/internal/auth.d.ts +2 -0
- package/dist/base/table-view/internal/auth.js +3 -1
- package/dist/base/table-view/internal/auth.js.map +1 -1
- package/dist/base/table-view/internal/column-conditions.d.ts +15 -0
- package/dist/base/table-view/internal/column-conditions.js +94 -0
- package/dist/base/table-view/internal/column-conditions.js.map +1 -0
- package/dist/base/table-view/internal/factory-helpers.d.ts +5 -0
- package/dist/base/table-view/internal/factory-helpers.js +55 -11
- package/dist/base/table-view/internal/factory-helpers.js.map +1 -1
- package/dist/base/table-view/internal/guards.js +19 -2
- package/dist/base/table-view/internal/guards.js.map +1 -1
- package/dist/base/table-view/internal/realtime.js +17 -0
- package/dist/base/table-view/internal/realtime.js.map +1 -1
- package/dist/base/table-view/internal/resource-form.js +1 -0
- package/dist/base/table-view/internal/resource-form.js.map +1 -1
- package/dist/base/table-view/internal/row-actions-schema.d.ts +296 -0
- package/dist/base/table-view/internal/row-actions-schema.js +62 -0
- package/dist/base/table-view/internal/row-actions-schema.js.map +1 -0
- package/dist/base/table-view/internal/validation.d.ts +22 -3
- package/dist/base/table-view/internal/validation.js +92 -2
- package/dist/base/table-view/internal/validation.js.map +1 -1
- package/dist/base/table-view/meta.d.ts +25 -1
- package/dist/base/table-view/meta.js +36 -4
- package/dist/base/table-view/meta.js.map +1 -1
- package/dist/base/table-view/options.d.ts +92 -16
- package/dist/base/table-view/options.js.map +1 -1
- package/dist/base/table-view/realtime.d.ts +8 -0
- package/dist/base/table-view/realtime.js.map +1 -1
- package/dist/base/table-view/routes.js +8 -4
- package/dist/base/table-view/routes.js.map +1 -1
- package/dist/base/table-view/schema.d.ts +36 -30
- package/dist/base/table-view/schema.js +11 -57
- package/dist/base/table-view/schema.js.map +1 -1
- package/dist/base/table-view/source.d.ts +1 -1
- package/dist/base/table-view/source.js +4 -1
- package/dist/base/table-view/source.js.map +1 -1
- package/dist/base/table-view.d.ts +2 -2
- package/dist/base/table-view.js.map +1 -1
- package/dist/base/top-list-card.d.ts +5 -5
- package/dist/base/types/action-target.d.ts +77 -20
- package/dist/base/types/confirm-dialog.d.ts +5 -1
- package/dist/base/types/custom-button.d.ts +5 -3
- package/dist/base/types/field-config.d.ts +4 -4
- package/dist/base/types/guards.d.ts +10 -0
- package/dist/base/types/row-action.d.ts +6 -1
- package/dist/base/types/size.d.ts +3 -1
- package/dist/client-ip.d.ts +6 -2
- package/dist/client-ip.js +4 -2
- package/dist/client-ip.js.map +1 -1
- package/dist/component.d.ts +14 -0
- package/dist/component.js.map +1 -1
- package/dist/data-controllers/members.d.ts +0 -3
- package/dist/data-controllers/members.js +30 -8
- package/dist/data-controllers/members.js.map +1 -1
- package/dist/db/internal/tenantLifecycle.model.js +5 -2
- package/dist/db/internal/tenantLifecycle.model.js.map +1 -1
- package/dist/guards.js +2 -3
- package/dist/guards.js.map +1 -1
- package/dist/hooks.d.ts +54 -10
- package/dist/hooks.js +12 -4
- package/dist/hooks.js.map +1 -1
- package/dist/index.d.ts +3 -3
- package/dist/index.js +3 -2
- package/dist/index.js.map +1 -1
- package/dist/internal/invite-membership.d.ts +9 -0
- package/dist/internal/invite-membership.js +25 -7
- package/dist/internal/invite-membership.js.map +1 -1
- package/dist/internal/member-changes.d.ts +24 -0
- package/dist/internal/member-changes.js +66 -0
- package/dist/internal/member-changes.js.map +1 -0
- package/dist/internal/tenant-ownership.d.ts +4 -1
- package/dist/internal/tenant-ownership.js +4 -10
- package/dist/internal/tenant-ownership.js.map +1 -1
- package/dist/invite-extensions/internal/form-slot.js +1 -1
- package/dist/invite-extensions/internal/form-slot.js.map +1 -1
- package/dist/invite-extensions.d.ts +0 -9
- package/dist/invite-extensions.js +1 -36
- package/dist/invite-extensions.js.map +1 -1
- package/dist/invite-resolution.js +6 -2
- package/dist/invite-resolution.js.map +1 -1
- package/dist/invites.js +36 -22
- package/dist/invites.js.map +1 -1
- package/dist/layout-banners.d.ts +40 -4
- package/dist/layout-banners.js +25 -3
- package/dist/layout-banners.js.map +1 -1
- package/dist/notifications/internal.d.ts +2 -2
- package/dist/notifications/internal.js.map +1 -1
- package/dist/notifications/sendable.d.ts +3 -2
- package/dist/notifications/sendable.js +2 -1
- package/dist/notifications/sendable.js.map +1 -1
- package/dist/notifications/types.d.ts +9 -0
- package/dist/page/controllers.d.ts +44 -0
- package/dist/page/controllers.js +14 -1
- package/dist/page/controllers.js.map +1 -1
- package/dist/page/internal/layout-filter.d.ts +10 -4
- package/dist/page/internal/layout-filter.js +27 -14
- package/dist/page/internal/layout-filter.js.map +1 -1
- package/dist/page/internal/metadata-helpers.d.ts +7 -0
- package/dist/page/internal/metadata-helpers.js +13 -2
- package/dist/page/internal/metadata-helpers.js.map +1 -1
- package/dist/page/internal/page-record.d.ts +34 -0
- package/dist/page/internal/page-record.js +87 -0
- package/dist/page/internal/page-record.js.map +1 -0
- package/dist/page/internal/page-registration.d.ts +2 -0
- package/dist/page/internal/page-registration.js.map +1 -1
- package/dist/page/layout-filter.js +31 -10
- package/dist/page/layout-filter.js.map +1 -1
- package/dist/page/metadata.js +36 -26
- package/dist/page/metadata.js.map +1 -1
- package/dist/page/types.d.ts +39 -4
- package/dist/page/types.js.map +1 -1
- package/dist/page.d.ts +1 -1
- package/dist/page.js +2 -1
- package/dist/page.js.map +1 -1
- package/dist/permissions-resolver.d.ts +26 -3
- package/dist/permissions-resolver.js +2 -1
- package/dist/permissions-resolver.js.map +1 -1
- package/dist/permissions.d.ts +7 -3
- package/dist/permissions.js +8 -6
- package/dist/permissions.js.map +1 -1
- package/dist/realtime.d.ts +24 -1
- package/dist/realtime.js +7 -1
- package/dist/realtime.js.map +1 -1
- package/dist/request-tenant.d.ts +17 -13
- package/dist/request-tenant.js +54 -42
- package/dist/request-tenant.js.map +1 -1
- package/dist/tenant-members.d.ts +26 -0
- package/dist/tenant-members.js +29 -0
- package/dist/tenant-members.js.map +1 -0
- package/dist/tenant-ownership.d.ts +4 -1
- package/dist/tenant-ownership.js +12 -17
- package/dist/tenant-ownership.js.map +1 -1
- package/dist/tenant-provisioning.d.ts +15 -0
- package/dist/tenant-provisioning.js +27 -0
- package/dist/tenant-provisioning.js.map +1 -0
- package/dist/tenant-scoped-model.d.ts +2 -1
- package/dist/tenant-scoped-model.js +4 -3
- package/dist/tenant-scoped-model.js.map +1 -1
- package/docs/1.core/10.layout-banners.md +57 -2
- package/docs/1.core/2.pages-and-modules.md +20 -11
- package/docs/1.core/4.permissions.md +34 -1
- package/docs/1.core/5.tenancy.md +37 -6
- package/docs/1.core/7.hooks.md +24 -4
- package/docs/1.core/8.realtime.md +24 -3
- package/docs/2.auth/2.authentication.md +3 -3
- package/docs/3.base/1.introduction.md +1 -0
- package/docs/3.base/10.display-blocks.md +20 -2
- package/docs/3.base/2.types.md +7 -5
- package/docs/3.base/3.forms.md +28 -8
- package/docs/3.base/4.tables.md +27 -9
- package/docs/3.base/5.charts-and-widgets.md +8 -4
- package/docs/3.base/6.layout.md +47 -1
- package/docs/3.base/8.data-types.md +16 -9
- package/docs/4.notifications/3.sending.md +4 -3
- package/docs/5.html-render/1.introduction.md +5 -7
- package/package.json +7 -11
|
@@ -7,7 +7,7 @@ description: "A layout banner is a global message the backend adds under the das
|
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
A layout banner is a global message the backend adds under the dashboard header: a colored strip carrying a line of text and its link buttons, or a component of your frontend layer. It renders on
|
|
10
|
+
A layout banner is a global message the backend adds under the dashboard header: a colored strip carrying a line of text and its link buttons, or a component of your frontend layer. It renders on the pages its [`scope`](#scope) selects — by default the application's pages, leaving the module area under `/modules` out — and banners stack by `order`.
|
|
11
11
|
|
|
12
12
|
Whether a banner shows is decided per request, on the server, while the site layout is built. The answer is part of the server-rendered page, so a banner never flashes in or out after hydration.
|
|
13
13
|
|
|
@@ -92,6 +92,60 @@ RegisterLayoutBanner({
|
|
|
92
92
|
|
|
93
93
|
Re-registering a `key` replaces the banner.
|
|
94
94
|
|
|
95
|
+
## Scope
|
|
96
|
+
|
|
97
|
+
A banner is about the application being built — eighteen overdue invoices, a stock alert — so by default it shows on the application's pages only, and stays out of the **module area**: every page under `MODULE_URL_PREFIX` (`/modules`), that is the modules page, the module store and each module's own back-office pages. A banner about the workspace itself, which must follow the user into a module — billing past due, a suspended workspace, a maintenance window — sets `scope: LayoutBannerScope.EVERYWHERE`.
|
|
98
|
+
|
|
99
|
+
`scope` is a plain option of the banner declaration, the same on the three kinds of banner — a text banner, a resolved banner and a component banner — with the same values and the same `"app"` default:
|
|
100
|
+
|
|
101
|
+
```typescript
|
|
102
|
+
import {
|
|
103
|
+
LayoutBannerScope,
|
|
104
|
+
LayoutBannerVariant,
|
|
105
|
+
RegisterLayoutBanner,
|
|
106
|
+
} from "@antelopejs/interface-dms/layout-banners";
|
|
107
|
+
|
|
108
|
+
// A text banner about the application: the default scope, written out.
|
|
109
|
+
RegisterLayoutBanner({
|
|
110
|
+
key: "my-app:overdue-invoices",
|
|
111
|
+
variant: LayoutBannerVariant.WARNING,
|
|
112
|
+
scope: LayoutBannerScope.APP,
|
|
113
|
+
text: "$my_app.banners.overdue_invoices",
|
|
114
|
+
actions: [{ label: "$my_app.banners.review", to: "/finance/invoices" }],
|
|
115
|
+
visible: async ({ tenantId }) => hasOverdueInvoices(tenantId),
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
// A resolved banner about the workspace: it follows the user into /modules.
|
|
119
|
+
RegisterLayoutBanner({
|
|
120
|
+
key: "saas:past-due",
|
|
121
|
+
variant: LayoutBannerVariant.ERROR,
|
|
122
|
+
scope: LayoutBannerScope.EVERYWHERE,
|
|
123
|
+
resolve: pastDueContent,
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// A component banner about the workspace, also shown inside /modules.
|
|
127
|
+
RegisterLayoutBanner({
|
|
128
|
+
key: "my-app:maintenance",
|
|
129
|
+
variant: LayoutBannerVariant.INFO,
|
|
130
|
+
scope: LayoutBannerScope.EVERYWHERE,
|
|
131
|
+
component: "MyAppMaintenanceBanner",
|
|
132
|
+
props: { statusUrl: "https://status.example.com" },
|
|
133
|
+
visible: async () => isMaintenanceScheduled(),
|
|
134
|
+
});
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| `scope` | Shows on |
|
|
138
|
+
| ------- | -------- |
|
|
139
|
+
| `"app"` (default) | Every page outside the module area. |
|
|
140
|
+
| `"everywhere"` | Every page of the dashboard layout, module area included. |
|
|
141
|
+
| `"modules"` | The module area only. |
|
|
142
|
+
|
|
143
|
+
The scope is decided by the page, not by the request: the site layout is fetched once and shared by every page, so each banner carries its scope and the dashboard applies it against the current route — in the server-rendered page, so a banner never flashes in or out after hydration, and again on each client-side navigation, so moving in or out of `/modules` adds or removes it without re-fetching anything. A banner's `visible` or `resolve` therefore does not see the route; the scope is the only per-page filter. A dismissal is per key, whatever page the banner was closed on.
|
|
144
|
+
|
|
145
|
+
::warning
|
|
146
|
+
**Behaviour change.** Before `scope` existed every banner showed on every page, module area included. A banner registered without `scope` now stays out of `/modules`: give `scope: LayoutBannerScope.EVERYWHERE` to the ones reporting a workspace-wide state.
|
|
147
|
+
::
|
|
148
|
+
|
|
95
149
|
## `LayoutBannerInfo`
|
|
96
150
|
|
|
97
151
|
| Field | Type | Description |
|
|
@@ -105,6 +159,7 @@ Re-registering a `key` replaces the banner.
|
|
|
105
159
|
| `order` | `number` | Stack order, ascending from the top. Defaults to `0`; ties keep the registration order. |
|
|
106
160
|
| `icon` | `string` | Leading icon. Defaults to one matching the variant. |
|
|
107
161
|
| `dismissible` | `boolean` | Whether the user can close the banner. Defaults to `false`. |
|
|
162
|
+
| `scope` | `LayoutBannerScope` | `"app"`, `"everywhere"` or `"modules"`: the pages the banner shows on. Defaults to `"app"`, every page outside `/modules`. See [Scope](#scope). |
|
|
108
163
|
| `visible` | `LayoutBannerVisibility` | Decides per request whether a `text` or `component` banner shows. Absent means always. |
|
|
109
164
|
| `resolve` | `LayoutBannerResolver` | Builds `{ text, actions? }` per request, or `undefined` to hide the banner. Exclusive with `text`, `component` and `visible`. |
|
|
110
165
|
|
|
@@ -128,6 +183,6 @@ The resolvers run whenever a client fetches its site layout — on a full page l
|
|
|
128
183
|
|
|
129
184
|
## Rendering
|
|
130
185
|
|
|
131
|
-
Banners render under the header of the dashboard layout, full width, in `order`, a text banner's `actions` on its right. Each one is its own live region: `error` banners carry `role="alert"`, `info` and `warning` banners `role="status"`.
|
|
186
|
+
Banners render under the header of the dashboard layout, on the pages their scope covers, full width, in `order`, a text banner's `actions` on its right. Each one is its own live region: `error` banners carry `role="alert"`, `info` and `warning` banners `role="status"`.
|
|
132
187
|
|
|
133
188
|
A dismissible banner shows a close button. The dismissal lasts for the browser session and is kept in the `dms-dismissed-banners` cookie, which the server reads too, so a dismissed banner is already absent from the server-rendered page. A banner that is not dismissible ignores any dismissal recorded under its key.
|
|
@@ -62,6 +62,7 @@ The third argument is the page layout, a `ComponentInfo` produced by a layout bu
|
|
|
62
62
|
| `order` | `number` | Sort order among siblings. |
|
|
63
63
|
| `icon` | `string` | Icon name. |
|
|
64
64
|
| `hidden` | `boolean` | Keep the entry out of the menu. Inherited from the resolved category when that category is hidden. |
|
|
65
|
+
| `parent` | `ControllerClass` | The page this one is a detail of: the breadcrumb shows it before this page ("Operations / Projects / <record>"), its route parameters filled from this page's own. Resolved at registration, served as `parentSlug`. Pages only. |
|
|
65
66
|
| `permission` | `Partial<Permission> \| Action` | Override the auto-derived page permission, or bind the page to an existing component `Action`. See [Permissions](/docs/interfaces/core/permissions). |
|
|
66
67
|
| `publicAccess` | `boolean` | Serve the page without authentication. Inherited from the resolved category. |
|
|
67
68
|
| `authOnly` | `boolean` | Require a logged-in user but no permission check. Inherited from the resolved category. |
|
|
@@ -212,7 +213,7 @@ Call `RegisterModule` before any page that references the module id: a page reso
|
|
|
212
213
|
| `description` | `string` | Description carried on the module root. |
|
|
213
214
|
| `icon` | `string` | Icon name. |
|
|
214
215
|
| `landingPage` | `string` | Id of the page the module opens on; `/modules/<id>` redirects to it, query and hash kept. Defaults to the first page by `order`. |
|
|
215
|
-
| `catalogCategory` | `string` | Category the module is filed under in the module catalog (`"Content"`, `"Developer"`…), may be a `$` i18n key; without one
|
|
216
|
+
| `catalogCategory` | `string` | Category the module is filed under in the module catalog (`"Content"`, `"Developer"`…), may be a `$` i18n key. The Modules page and the store both file the module under it; without one, a store module takes the category of the store catalog, and any other goes under "Other". Unrelated to menu categories (`MenuOptions.category`, `defaultCategory`). Formerly `category`. |
|
|
216
217
|
| `defaultCategory` | `{ displayName?, icon?, order?, urlSlug? }` | Groups the module's pages that declare `module` without an explicit `category`. Without it, those pages attach directly to the module root. |
|
|
217
218
|
|
|
218
219
|
The generated default category uses `urlSlug` `"pages"` unless you override it, and falls back to the label `"$menu.section.pages"` when `displayName` is empty or omitted.
|
|
@@ -388,12 +389,20 @@ interface PageLayout<T = unknown> {
|
|
|
388
389
|
Registering a page installs a `PageLayoutHandler` for its `fullSlug`:
|
|
389
390
|
|
|
390
391
|
```typescript
|
|
391
|
-
|
|
392
|
-
user: User | undefined
|
|
393
|
-
memberModel: TenantMemberModel
|
|
394
|
-
roleModel: RoleModel
|
|
395
|
-
tenantId: string
|
|
396
|
-
|
|
392
|
+
interface PageLayoutRequest {
|
|
393
|
+
user: User | undefined;
|
|
394
|
+
memberModel: TenantMemberModel;
|
|
395
|
+
roleModel: RoleModel;
|
|
396
|
+
tenantId: string;
|
|
397
|
+
/** The page URL's query, for header-button availability. */
|
|
398
|
+
query?: Readonly<Record<string, string>>;
|
|
399
|
+
/** The page URL's route parameters (`:id`), likewise. */
|
|
400
|
+
params?: Readonly<Record<string, string>>;
|
|
401
|
+
/** Passed to the permissions resolvers: `{ preview }` when a role preview reads the layout. */
|
|
402
|
+
resolverContext?: PermissionsResolverContext;
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
type PageLayoutHandler = (request: PageLayoutRequest) => Promise<PageLayout>;
|
|
397
406
|
```
|
|
398
407
|
|
|
399
408
|
The handler drops the components the caller has no permission for and applies each component's own filter. Native upload tokens are bound to the exact page/component mount during page registration, not stamped by this handler; upload and metadata requests still check live authorization ([File storage](/docs/components/file-storage)). A page with `publicAccess`, `authOnly` or `noComponentPermissions` skips the filtering step and returns every component. Look a handler up with `GetPageLayoutBySlug(slug)`, which returns `undefined` for an unknown slug:
|
|
@@ -416,12 +425,12 @@ async function renderLayout(
|
|
|
416
425
|
tenantId: string,
|
|
417
426
|
): Promise<PageLayout | undefined> {
|
|
418
427
|
const handler = GetPageLayoutBySlug(slug);
|
|
419
|
-
return handler?.(
|
|
428
|
+
return handler?.({
|
|
420
429
|
user,
|
|
421
|
-
GetModel(TenantMemberModel, tenantId),
|
|
422
|
-
GetModel(RoleModel, tenantId),
|
|
430
|
+
memberModel: GetModel(TenantMemberModel, tenantId),
|
|
431
|
+
roleModel: GetModel(RoleModel, tenantId),
|
|
423
432
|
tenantId,
|
|
424
|
-
);
|
|
433
|
+
});
|
|
425
434
|
}
|
|
426
435
|
```
|
|
427
436
|
|
|
@@ -45,6 +45,8 @@ RegisterPermission("reports.export", {
|
|
|
45
45
|
| `dependencies` | `string[]` | Permission ids this one needs to be of any use. The roles editor grants them with it (and takes it away with them), and a role is saved with them and the ids they sit under. `HasPermission` requires them, like the ids above: a registered dependency the set lacks makes the permission count for nothing. An unregistered one counts as held. |
|
|
46
46
|
| `defaultGranted` | `boolean` | Treat the permission as held by every caller, without a role grant. |
|
|
47
47
|
|
|
48
|
+
A table view's actions declare their `dependencies` themselves: `edit` and `export` need `view`, and every action only reached from the rows — `delete`, `archive`, `viewArchived`, `export`, and `add`/`edit`/`view` when their form opens over the table — needs `list`; `restore` needs `viewArchived`. A form opening as its own page and `select` need no `list`.
|
|
49
|
+
|
|
48
50
|
`RegisterPermission(id, permission)` takes the id twice — once as the registry key, once inside the record — so keep them equal.
|
|
49
51
|
|
|
50
52
|
`UnregisterPermission(id)` drops a permission from the grantable tree again. It exists for registrations undone while the process keeps running — a page extension whose module stopped, for instance. A permission left behind after its surface is gone stays offered in the roles editor and stays grantable, so anything that registers a permission conditionally should unregister it on the same condition.
|
|
@@ -174,11 +176,42 @@ type PermissionsResolverFn = (
|
|
|
174
176
|
userId: string,
|
|
175
177
|
tenantId: string,
|
|
176
178
|
currentPermissions: Set<string>,
|
|
179
|
+
context: PermissionsResolverContext,
|
|
177
180
|
) => Promise<Set<string>> | Set<string>;
|
|
181
|
+
|
|
182
|
+
interface PermissionsResolverContext {
|
|
183
|
+
/** Set when the call previews a role rather than computing a member's set. */
|
|
184
|
+
preview?: PermissionsResolverPreview;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
interface PermissionsResolverPreview {
|
|
188
|
+
/** The previewed role's permissions, before any resolver reshaped them. */
|
|
189
|
+
rolePermissions: ReadonlySet<string>;
|
|
190
|
+
}
|
|
178
191
|
```
|
|
179
192
|
|
|
180
193
|
`PermissionsResolverInfo` carries `id`, `resolver` and `order`.
|
|
181
194
|
|
|
195
|
+
### Role previews
|
|
196
|
+
|
|
197
|
+
The roles editor's **Preview as role** runs the resolvers too, over the previewed role's set, and tells them so with `context.preview`. The `userId` of such a call is the person previewing. A resolver that grants or withholds by the user's standing (an owner handed every permission of the plan, a screen kept for owners) must check the flag, and shape the set as for a member holding only that role:
|
|
198
|
+
|
|
199
|
+
```typescript
|
|
200
|
+
RegisterPermissionsResolver({
|
|
201
|
+
id: "my_app.plan-limits",
|
|
202
|
+
order: 100,
|
|
203
|
+
resolver: async (userId, tenantId, currentPermissions, context) => {
|
|
204
|
+
const allowed = await loadPlanPermissions(tenantId);
|
|
205
|
+
if (!context.preview && (await isWorkspaceOwner(userId, tenantId))) {
|
|
206
|
+
return allowed;
|
|
207
|
+
}
|
|
208
|
+
return new Set([...currentPermissions].filter((p) => allowed.has(p)));
|
|
209
|
+
},
|
|
210
|
+
});
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The DMS always passes `context`: `{}` outside a preview.
|
|
214
|
+
|
|
182
215
|
Semantics the interface guarantees:
|
|
183
216
|
|
|
184
217
|
- Resolvers run in ascending `order`, each seeing the previous one's result; equal `order` runs in registration order, which is module load order.
|
|
@@ -187,7 +220,7 @@ Semantics the interface guarantees:
|
|
|
187
220
|
- The registration is removed when the registering module unloads; there is no unregister call.
|
|
188
221
|
- Register in `construct()` so the resolver is active before the API serves requests.
|
|
189
222
|
|
|
190
|
-
`ApplyPermissionsResolvers(userId, tenantId, basePermissions)` runs the pipeline and resolves to the effective set. Prefer `GetEffectiveUserPermissions`, which computes the base set for you and calls it.
|
|
223
|
+
`ApplyPermissionsResolvers(userId, tenantId, basePermissions, context?)` runs the pipeline, handing `context` (`{}` when omitted) to every resolver, and resolves to the effective set. Prefer `GetEffectiveUserPermissions`, which computes the base set for you and calls it.
|
|
191
224
|
|
|
192
225
|
A resolver shapes a permission set. It cannot deny a surface that consults no permission set, and it cannot revoke a `defaultGranted` permission, which `HasPermission` grants after the resolvers ran. To cut a tenant off from the product entirely, register a tenant access gate instead ([Tenancy](/docs/interfaces/core/tenancy)).
|
|
193
226
|
|
package/docs/1.core/5.tenancy.md
CHANGED
|
@@ -127,7 +127,7 @@ Table `roles` in `dms-tenant` — the tenant's roles.
|
|
|
127
127
|
|
|
128
128
|
## Resolve the Request's Tenant
|
|
129
129
|
|
|
130
|
-
`getRequestTenantId(ctx)`
|
|
130
|
+
`getRequestTenantId(ctx)` returns the tenant of the request's **verified** credential, bound by the DMS auth decorators (`AuthUser`, `AuthRawUser`, `IfAuthUser` with a valid token, `AuthOwnerOnly`, `AuthUserWithPermission`, the auth a `PageController` installs), the tenant guards or `authenticateRequestUser`. Without a verified credential (anonymous request, invalid or forged token) it returns `DEFAULT_TENANT_ID`; a token is never read for its tenant before its signature, user and session are checked.
|
|
131
131
|
|
|
132
132
|
```typescript
|
|
133
133
|
import {
|
|
@@ -148,11 +148,11 @@ export class WorkspaceController extends Controller("/api/workspace") {
|
|
|
148
148
|
}
|
|
149
149
|
```
|
|
150
150
|
|
|
151
|
-
|
|
151
|
+
Call it in the handler, after the route's auth decorator ran. A parameter or property provider that may run before it uses `resolveRequestTenantId(ctx)` (async), which verifies a bearer token itself and binds its tenant; `TenantScopedModel` does. Resolving a tenant is still not authorization: check membership and permissions for the operation.
|
|
152
152
|
|
|
153
153
|
## Scope a Model to the Request's Tenant
|
|
154
154
|
|
|
155
|
-
`TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass,
|
|
155
|
+
`TenantScopedModel(ModelClass)` is a parameter and property decorator that injects `GetModel(ModelClass, await resolveRequestTenantId(ctx))`: the tenant of the verified credential, `DEFAULT_TENANT_ID` without one.
|
|
156
156
|
|
|
157
157
|
```typescript
|
|
158
158
|
import { Controller, Get } from "@antelopejs/interface-api";
|
|
@@ -279,7 +279,7 @@ await applyTenantOwnership(GetModel(UserModel), userId, tenantId, {
|
|
|
279
279
|
| `isTenantOwner` | `boolean` | Ownership flag after the write. |
|
|
280
280
|
| `invitedBy` | `string \| null` | Recorded on insert. Ignored when the membership row already exists. |
|
|
281
281
|
|
|
282
|
-
`applyTenantOwnership` fires `Hook.MEMBER_BEING_CHANGED` before any write, so a module can veto it. When the user is not yet a member, it also fires `Hook.MEMBER_BEING_ADDED` before the write and `Hook.MEMBER_ADDED` after it. For an existing member it updates `roleIds` and `isTenantOwner` and fires neither of those two ([Hooks](/docs/interfaces/core/hooks)).
|
|
282
|
+
`applyTenantOwnership` goes through [`changeTenantMembers`](#changetenantmembers), which fires `Hook.MEMBER_BEING_CHANGED` before any write, so a module can veto it. When the user is not yet a member, it also fires `Hook.MEMBER_BEING_ADDED` before the write and `Hook.MEMBER_ADDED` after it. For an existing member it updates `roleIds` and `isTenantOwner` and fires neither of those two ([Hooks](/docs/interfaces/core/hooks)).
|
|
283
283
|
|
|
284
284
|
Two helpers keep the legacy platform-owner flag on the user row aligned with tenant ownership:
|
|
285
285
|
|
|
@@ -313,9 +313,40 @@ The registration is removed when the registering module unloads, and the mode st
|
|
|
313
313
|
|
|
314
314
|
Membership and invite producers coordinate with tenant deletion through `@antelopejs/interface-dms/tenant-lifecycle`. This is a write-lifecycle contract, not an authorization check or a transaction: authorize the caller separately.
|
|
315
315
|
|
|
316
|
+
### `changeTenantMembers`
|
|
317
|
+
|
|
318
|
+
Every insert, update or delete of a `TenantMemberModel` or `UserInviteModel` row goes through one entry point, `changeTenantMembers` from `@antelopejs/interface-dms/tenant-members`. The DMS uses it for every membership and invitation write it makes: adding a member, changing a member's roles or ownership (from the members table or the owner route), removing a member, moving the holders of a deleted role, leaving each tenant when an account is deleted, and creating, editing, resending, cancelling, expiring and accepting invitations. A module writing these rows itself must use it too; there is no other supported write path, and a raw `GetModel(...).insert/update/delete` on them skips both the barrier and the policy hook.
|
|
319
|
+
|
|
320
|
+
```typescript
|
|
321
|
+
changeTenantMembers<T>(
|
|
322
|
+
tenantId: string,
|
|
323
|
+
plan: () => Promise<{ changes: MemberChange[]; write: () => Promise<T> }>,
|
|
324
|
+
): Promise<T>
|
|
325
|
+
```
|
|
326
|
+
|
|
327
|
+
It runs, in order:
|
|
328
|
+
|
|
329
|
+
1. `runTenantLifecycleOperation(tenantId, ...)`: refused at once, before anything is read, while a [barrier](#pausing-admission) is held or once admission is closed;
|
|
330
|
+
2. `plan()`, inside the admission, which reads the current rows and describes the change;
|
|
331
|
+
3. `Hook.MEMBER_BEING_CHANGED` once per `MemberChange`, with `{ tenantId, ...change }`. A throwing handler refuses the write, replays of an invitation acceptance included;
|
|
332
|
+
4. `write()`, then the admission is released as `runTenantLifecycleOperation` describes.
|
|
333
|
+
|
|
334
|
+
A `MemberChange` has a `kind`:
|
|
335
|
+
|
|
336
|
+
| `kind` | Fields | When |
|
|
337
|
+
| ------ | ------ | ---- |
|
|
338
|
+
| `added` | `userId`, `isTenantOwner`, `roleIds`, `deliveryId?` | A user joins: direct add, or an invitation accepted (`deliveryId` set) |
|
|
339
|
+
| `removed` | `userId`, `isTenantOwner` | A member is removed, or leaves by deleting their account |
|
|
340
|
+
| `roles` | `userId`, `roleIds`, `previousRoleIds` | A member's roles change, including when a deleted role is reassigned |
|
|
341
|
+
| `ownership` | `userId`, `isTenantOwner`, `previousIsTenantOwner` | A member is given or loses the owner role |
|
|
342
|
+
|
|
343
|
+
One edit setting both roles and ownership announces two changes. A write that changes no membership (an invitation created, edited or retired) passes `changes: []`: it is still admitted, and invitation creation keeps its own `Hook.INVITE_BEING_CREATED`. Do not call `changeTenantMembers` from inside another `changeTenantMembers` or `runTenantLifecycleOperation` callback.
|
|
344
|
+
|
|
345
|
+
Deleting an account makes one call per tenant the user belongs to, in turn. A tenant holding a barrier refuses its call, which stops the deletion there: the account and its remaining memberships stay, and the user can retry once the barrier is released.
|
|
346
|
+
|
|
316
347
|
### `runTenantLifecycleOperation`
|
|
317
348
|
|
|
318
|
-
`runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T>` admits one invocation before calling `work`, then returns its result after releasing that invocation's admission. Closed admission rejects without running `work`.
|
|
349
|
+
`runTenantLifecycleOperation<T>(tenantId: string, work: () => Promise<T>): Promise<T>` admits one invocation before calling `work`, then returns its result after releasing that invocation's admission. Closed admission rejects without running `work`. Membership and invitation writes use it through `changeTenantMembers`; call it directly only for other work that must coordinate with tenant deletion, and await every effect, with no detached writer.
|
|
319
350
|
|
|
320
351
|
A successful callback releases its admission. A deliberate HTTP 4xx refusal also attempts release and rethrows the original refusal; other failures retain admission because their effects may be indeterminate. Retrying is a new invocation and never retires the failed one's identity. There is no automatic expiry or reconciliation API.
|
|
321
352
|
|
|
@@ -329,7 +360,7 @@ A module that must validate a tenant's inventory with no membership writer in fl
|
|
|
329
360
|
| `pauseTenantLifecycleAdmission(tenantId, barrierId, expectedRevision)` | Publishes the barrier with one compare-and-set on `expectedRevision`. Resolves `false` when the revision moved, a barrier is held or admission is closed. |
|
|
330
361
|
| `resumeTenantLifecycleAdmission(tenantId, barrierId)` | Releases this barrier; with no barrier held, it invalidates the revision so a delayed `pause` fails. Resolves `false` for another owner's barrier or closed admission. |
|
|
331
362
|
|
|
332
|
-
While a barrier is held, `runTenantLifecycleOperation` rejects new work without running it
|
|
363
|
+
While a barrier is held, `runTenantLifecycleOperation` rejects new work without running it, with an `HTTPResult` 409 (`Tenant lifecycle admission is paused`): a deliberate refusal with nothing written and no admission retained, which the caller may retry once the barrier is released. Admitted work keeps running. Observe the revision before checking your own business intent, pause with that revision, recheck the intent, then wait for an observation with no active attempt before validating. A `false` from `pause` needs a new observation, not a blind retry. Use a unique `barrierId` per phase, and do not observe or pause from inside a `runTenantLifecycleOperation` callback: the caller would wait for itself.
|
|
333
364
|
|
|
334
365
|
### `closeTenantLifecycleAdmission`
|
|
335
366
|
|
package/docs/1.core/7.hooks.md
CHANGED
|
@@ -76,7 +76,7 @@ UnregisterHook(Hook.MEMBER_ADDED, onMemberAdded);
|
|
|
76
76
|
| `Hook.INVITE_CREATED` | `"invite:created"` | `InviteCreatedHookPayload` | `undefined` |
|
|
77
77
|
| `Hook.INVITE_DELETED` | `"invite:deleted"` | `InviteDeletedHookPayload` | `undefined` |
|
|
78
78
|
| `Hook.MEMBER_BEING_ADDED` | `"member:being-added"` | `MemberHookPayload` | `undefined` |
|
|
79
|
-
| `Hook.MEMBER_BEING_CHANGED` | `"member:being-changed"` | `MemberChangeHookPayload` | `undefined` —
|
|
79
|
+
| `Hook.MEMBER_BEING_CHANGED` | `"member:being-changed"` | `MemberChangeHookPayload` | `undefined` — fired by `changeTenantMembers` under tenant lifecycle admission before every membership write (`kind`: `added`, `removed`, `roles`, `ownership`); throwing vetoes the write |
|
|
80
80
|
| `Hook.MEMBER_ADDED` | `"member:added"` | `MemberHookPayload` | `undefined` |
|
|
81
81
|
| `Hook.MEMBER_REMOVED` | `"member:removed"` | `MemberRemovedHookPayload` | `undefined` |
|
|
82
82
|
| `Hook.USER_REGISTERED` | `"user:registered"` | `UserRegisteredHookPayload` | `undefined` |
|
|
@@ -89,7 +89,7 @@ The `*_BEING_*` members fire before the write they precede; the past-tense membe
|
|
|
89
89
|
```typescript
|
|
90
90
|
interface TenantProvisioningHookPayload {
|
|
91
91
|
tenantId: string;
|
|
92
|
-
userId
|
|
92
|
+
userId?: string; // absent for the `default` tenant
|
|
93
93
|
extras: Record<string, unknown>;
|
|
94
94
|
}
|
|
95
95
|
|
|
@@ -141,7 +141,17 @@ interface UserDeletedHookPayload {
|
|
|
141
141
|
|
|
142
142
|
The shapes are not uniform, and the difference between the addition and removal payloads is deliberate: `MEMBER_BEING_ADDED` and `MEMBER_ADDED` describe one member with `userId`, while `MEMBER_REMOVED` carries `userIds`, because a removal can cover several members at once. Let the handler's inferred parameter type guide you.
|
|
143
143
|
|
|
144
|
-
`MemberChangeHookPayload`
|
|
144
|
+
`MemberChangeHookPayload` is `{ tenantId } & MemberChange`, a union discriminated by `kind`:
|
|
145
|
+
|
|
146
|
+
```typescript
|
|
147
|
+
type MemberChange =
|
|
148
|
+
| { kind: "added"; userId: string; isTenantOwner: boolean; roleIds: string[]; deliveryId?: string }
|
|
149
|
+
| { kind: "removed"; userId: string; isTenantOwner: boolean }
|
|
150
|
+
| { kind: "roles"; userId: string; roleIds: string[]; previousRoleIds: string[] }
|
|
151
|
+
| { kind: "ownership"; userId: string; isTenantOwner: boolean; previousIsTenantOwner: boolean };
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
Read `kind` first and return early for the kinds your policy does not limit. An invitation acceptance is an `added` change carrying its `deliveryId`, vetoed before the acceptance is persisted, including on replay: a `deliveryId` is not a policy exemption. See [`changeTenantMembers`](/docs/interfaces/core/tenancy#changetenantmembers) for which writes announce which kind.
|
|
145
155
|
|
|
146
156
|
`UserDeletedHookPayload.tenantIds` lists the workspaces the user was a member of, all left before the deletion; delete the per-user rows your module keeps in them. `reason` is `"self"` today, the user deleting their own account.
|
|
147
157
|
|
|
@@ -253,7 +263,17 @@ The flip side is the usual one for a `*_BEING_*` hook: a handler that throws for
|
|
|
253
263
|
|
|
254
264
|
`extras` is opaque by design. The DMS reads no key of it, validates nothing, and stores nothing — it carries the object from the caller of the provisioning flow to the handlers, and each handler writes what it recognises. The shape is a contract between that caller and that handler, one the DMS is deliberately not a party to, so a product can start collecting a new field without waiting on a DMS release.
|
|
255
265
|
|
|
256
|
-
|
|
266
|
+
It fires once per tenant, when the tenant is created, and never again on a later start. The DMS fires it for the `default` tenant, on the start that creates it, once every `DATABASE_INITIALIZED` handler has finished, so a module's tables and settings are ready; that payload has no `userId`, since the `default` tenant has no owner until onboarding. A SaaS module such as **`dms-saas`** fires it for each workspace its signup flow creates.
|
|
267
|
+
|
|
268
|
+
This is where a module writes the **default data** a tenant starts with — templates, folders, a sender, a demo history. Written once, that data belongs to the tenant: the module does not write it again on a later start, does not compare it with what members changed since, and does not wait for the tenant from `start()` or `DATABASE_INITIALIZED`.
|
|
269
|
+
|
|
270
|
+
```typescript
|
|
271
|
+
RegisterHook(Hook.TENANT_BEING_PROVISIONED, async ({ tenantId }) => {
|
|
272
|
+
await writeDefaultFolders(tenantId);
|
|
273
|
+
});
|
|
274
|
+
```
|
|
275
|
+
|
|
276
|
+
An emitter fires the hook through `ProvisionTenant` from `@antelopejs/interface-dms/tenant-provisioning`, never with a bare `ExecuteHooks`. It registers the tenant as an instance of the `dms-tenant` schema first, then runs the handlers, so no handler runs before the instance exists, whatever order the modules registered in.
|
|
257
277
|
|
|
258
278
|
## Contribute to a Tenant Data Export
|
|
259
279
|
|
|
@@ -17,7 +17,7 @@ Subscription is derived, not requested by the publisher:
|
|
|
17
17
|
|
|
18
18
|
1. A page — or a component on it — calls `RegisterPageTopic(pageId, topic)` at registration time. `pageId` is the page's `fullId` ([Pages and Modules](/docs/interfaces/core/pages-and-modules)), not its slug.
|
|
19
19
|
2. A client opens a realtime session and reports which page it is on. The backend checks that the user may access that page, then subscribes the session to every topic registered for that page id, and drops the topics that are no longer wanted.
|
|
20
|
-
3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic
|
|
20
|
+
3. `PublishMessage(topic, type)` delivers to the sessions currently subscribed to `topic` — with a `tenantId`, only to those of that tenant (see [Tenant-scoped messages](#tenant-scoped-messages)).
|
|
21
21
|
|
|
22
22
|
So a publisher never addresses a page or a user. It addresses a topic, and the page-topic registry decides who is listening.
|
|
23
23
|
|
|
@@ -86,7 +86,7 @@ PublishMessage(
|
|
|
86
86
|
| --------- | ----------- |
|
|
87
87
|
| `topic` | The topic to publish on. Subscribers are the sessions on a page that registered this topic. |
|
|
88
88
|
| `type` | The event type carried with the message, for subscribers that distinguish several kinds of event on one topic. |
|
|
89
|
-
| `options` | Optional payload and
|
|
89
|
+
| `options` | Optional payload, actor and tenant. |
|
|
90
90
|
|
|
91
91
|
### `PublishMessageOptions`
|
|
92
92
|
|
|
@@ -94,12 +94,32 @@ PublishMessage(
|
|
|
94
94
|
| ------ | ---- | ----------- |
|
|
95
95
|
| `payload` | `Record<string, unknown>` | Data carried with the event. |
|
|
96
96
|
| `actorId` | `string` | Identifies who caused the event, so a client can ignore an event it triggered itself. |
|
|
97
|
+
| `tenantId` | `string` | The tenant the event is about: only the sessions of that tenant receive it. Omitted, the message is global and reaches every tenant. |
|
|
97
98
|
|
|
98
99
|
`PublishMessage` resolves once the message has been handed to the broker. It does not report how many sessions received it, and publishing on a topic no page registered is not an error — it simply reaches nobody.
|
|
99
100
|
|
|
101
|
+
### Tenant-scoped messages
|
|
102
|
+
|
|
103
|
+
Page topics are shared by every tenant: a page registers the same topic whichever workspace views it. A message published without a `tenantId` therefore reaches the sessions of **every** workspace on that page — each one refetches, and a paused live page counts the message in its "N new" banner. Publish a workspace's data with the tenant it belongs to:
|
|
104
|
+
|
|
105
|
+
```typescript
|
|
106
|
+
import { getRequestTenantId } from "@antelopejs/interface-dms/request-tenant";
|
|
107
|
+
|
|
108
|
+
await PublishMessage(CAMPAIGN_TOPIC, "campaign.sent", {
|
|
109
|
+
payload: { count: sent.length },
|
|
110
|
+
tenantId: getRequestTenantId(ctx), // or the tenant a job runs for
|
|
111
|
+
});
|
|
112
|
+
```
|
|
113
|
+
|
|
114
|
+
A session is bound to the tenant of the request that opened it, and receives the messages of that tenant plus the global ones. When the browser switches workspace (or account), the client opens a new session in the new one, and the server ends a session whose page requests come from a workspace or account other than its own, so the previous workspace's messages stop with it. A session on the connection a browser's tabs share is ended alone: the other tabs' sessions on it stay. Keep messages without a tenant for what is the same for every workspace: a platform-wide setting, a catalogue shared by all tenants.
|
|
115
|
+
|
|
116
|
+
A session on a platform page (a page of a module, which only the platform owner opens) receives the messages of **every** tenant on that page's topics, whichever workspace the owner has open: such a page lists the data of all of them.
|
|
117
|
+
|
|
118
|
+
The table views of the [Base](/docs/interfaces/base/tables#realtime) domain publish their row changes and presence for the writer's tenant, or for every tenant when their rows live in the shared schema. Messages to one user (notifications) and the menu invalidations already name their audience in the topic.
|
|
119
|
+
|
|
100
120
|
## Subscribe from the Backend
|
|
101
121
|
|
|
102
|
-
`SubscribeMessage` receives every message published on a topic, whichever instance published it: the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
|
|
122
|
+
`SubscribeMessage` receives every message published on a topic, whichever instance and whichever tenant published it (a handler serving one tenant filters on `message.tenantId` itself): the subscription rides the same broker as `PublishMessage`, so what one instance publishes reaches subscribers on every other.
|
|
103
123
|
|
|
104
124
|
```typescript
|
|
105
125
|
import { SubscribeMessage } from "@antelopejs/interface-dms/realtime";
|
|
@@ -132,6 +152,7 @@ type UnsubscribeMessage = () => void;
|
|
|
132
152
|
| `type` | `string` | Event type given to `PublishMessage`. |
|
|
133
153
|
| `payload` | `Record<string, unknown>` | Data carried with the event, when the publisher sent any. |
|
|
134
154
|
| `actorId` | `string` | Who caused the event, when the publisher named an actor. |
|
|
155
|
+
| `tenantId` | `string` | The tenant the message was published for; absent on a global message. |
|
|
135
156
|
| `ts` | `number` | Publication timestamp. |
|
|
136
157
|
|
|
137
158
|
Paired with `PublishMessage`, this turns the DMS's broker into one a module can use for realtime channels of its own — its own endpoint subscribes here and the DMS carries the messages — instead of bringing a second broker to do the same job.
|
|
@@ -7,7 +7,7 @@ description: "Authentication turns an incoming HTTP request into a `User` row."
|
|
|
7
7
|
|
|
8
8
|
## Overview
|
|
9
9
|
|
|
10
|
-
Authentication turns an incoming HTTP request into a `User` row. The interface exposes one plain function, `authenticateRequestUser`, and four decorators
|
|
10
|
+
Authentication turns an incoming HTTP request into a `User` row. The interface exposes one plain function, `authenticateRequestUser`, and four decorators with the `CreateAuthDecorator` contract of `@antelopejs/interface-auth`: `AuthRawUser`, `AuthUser`, `IfAuthUser` and `AuthOwnerOnly`. Each of them, and `authenticateRequestUser`, binds the verified user and tenant to the request, which [`getRequestTenantId`](/docs/interfaces/core/tenancy#resolve-the-requests-tenant) then answers.
|
|
11
11
|
|
|
12
12
|
All of them read the bearer token from the request's `Authorization` header (`Authorization: Bearer <token>`) and run the same two steps:
|
|
13
13
|
|
|
@@ -113,7 +113,7 @@ export class MaintenanceController extends Controller("/api/maintenance") {
|
|
|
113
113
|
|
|
114
114
|
## Gate Every Route on a Controller
|
|
115
115
|
|
|
116
|
-
`AuthRawUser`, `AuthUser` and `IfAuthUser`
|
|
116
|
+
`AuthRawUser`, `AuthUser` and `IfAuthUser` follow the `CreateAuthDecorator` contract, so they apply to a parameter, to a property, or to the controller class itself. A property provider is a computed property of the controller, resolved on every request the controller handles whether or not the handler reads it — so a decorated property gates the whole controller and gives the handlers a typed `this.user`.
|
|
117
117
|
|
|
118
118
|
```typescript
|
|
119
119
|
import { Controller, Get, JSONBody, Post } from "@antelopejs/interface-api";
|
|
@@ -206,7 +206,7 @@ export const AuthBetaTester = MakeParameterAndPropertyDecorator(
|
|
|
206
206
|
|
|
207
207
|
This is how the DMS builds its tenant guards: `AuthTenantOwner` and `AuthTenantMember` in `@antelopejs/interface-dms/guards` both start from `authenticateRequestUser`, then resolve the request's tenant and check the caller's membership on top of it.
|
|
208
208
|
|
|
209
|
-
A provider is also the way to add a requirement without losing the built-in one. The `CreateAuthDecorator` contract lets a call site pass a replacement validator — `@AuthUser(callback)` — but that callback replaces the whole validation step, signature check included, and its return type is constrained to `boolean | User
|
|
209
|
+
A provider is also the way to add a requirement without losing the built-in one. The `CreateAuthDecorator` contract lets a call site pass a replacement validator — `@AuthUser(callback)` — but that callback replaces the whole validation step, signature check included, and its return type is constrained to `boolean | User`; such a call binds no tenant to the request. This interface exports no token-verification primitive, so a replacement validator cannot redo the signature check it removes.
|
|
210
210
|
|
|
211
211
|
## Decorator Reference
|
|
212
212
|
|
|
@@ -30,6 +30,7 @@ The DMS documentation carries task-shaped chapters on the same components, organ
|
|
|
30
30
|
| `top-list-card` | `TopListCard` | yes | [Charts and Widgets](/docs/interfaces/base/charts-and-widgets) |
|
|
31
31
|
| `period-selector` | `PeriodSelector` | yes | [Charts and Widgets](/docs/interfaces/base/charts-and-widgets) |
|
|
32
32
|
| `live-toggle` | `LiveToggle` | yes | [Display Blocks](/docs/interfaces/base/display-blocks#live-toggle) |
|
|
33
|
+
| `page-toolbar` | `PageToolbar`, `pageToolbar` | yes | [Display Blocks](/docs/interfaces/base/display-blocks#page-toolbar) |
|
|
33
34
|
| `grid` | `Grid`, `GridRow` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
|
|
34
35
|
| `stack` | `HStack`, `VStack`, `Spacer` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
|
|
35
36
|
| `tab` | `Tab` | yes | [Layout and Containers](/docs/interfaces/base/layout) |
|
|
@@ -72,7 +72,7 @@ A block that can drop its frame does so with `card?: boolean` (`KeyValueList`, `
|
|
|
72
72
|
|
|
73
73
|
| Props | Own options |
|
|
74
74
|
| ------------------- | --------------------------------------------------------- |
|
|
75
|
-
| `StatGroupProps` | `items`, `layout` (`StatGroupLayout`), `columns`, `label` |
|
|
75
|
+
| `StatGroupProps` | `items`, `layout` (`StatGroupLayout`), `columns`, `columnsFrom` (`StatGroupColumnsFrom`: `sm` / `md` / `lg` / `xl`, the group width from which all `columns` sit side by side), `label` |
|
|
76
76
|
| `KeyValueListProps` | `items`, `title`, `card`, `dense`, `columns`, `currency` |
|
|
77
77
|
| `NavCardGridProps` | `items`, `title`, `description`, `columns` |
|
|
78
78
|
|
|
@@ -127,10 +127,28 @@ Card({ title: "Connectors", count: 4 })
|
|
|
127
127
|
|
|
128
128
|
## Live Toggle
|
|
129
129
|
|
|
130
|
-
`LiveToggle(options?)` (subpath `live-toggle`, block type `LiveToggle`, component `dms-live-toggle-block`, group `data`) is the Live / Pause switch of a page declared with `live` (`PageLiveOptions` from `@antelopejs/interface-dms/page`). `LiveToggleProps` extends `BaseComponentProps`: `align` (`LIVE_TOGGLE_ALIGNS`: `left`, `center`, `right`; default `right`) `showUpdatedAt` (default `true`), the time since the page last read its data, counted in the browser, `variant` (`LIVE_TOGGLE_VARIANTS`: `segmented`, `pill`; default `segmented`) and `missedLabel`, an i18n key or text given `{count}` naming the missed events while paused. The state belongs to the page, not to the block; on a page without `live` the block renders nothing.
|
|
130
|
+
`LiveToggle(options?)` (subpath `live-toggle`, block type `LiveToggle`, component `dms-live-toggle-block`, group `data`) is the Live / Pause switch of a page declared with `live` (`PageLiveOptions` from `@antelopejs/interface-dms/page`). `LiveToggleProps` extends `BaseComponentProps`: `align` (`LIVE_TOGGLE_ALIGNS`: `left`, `center`, `right`; default `right`) `showUpdatedAt` (default `true`), the time since the page last read its data, counted in the browser, `variant` (`LIVE_TOGGLE_VARIANTS`: `segmented`, `pill`; default `segmented`), `size` (`LIVE_TOGGLE_SIZES`: `xs`, `sm`, `md`; default the holding `PageToolbar`'s, else `sm`) and `missedLabel`, an i18n key or text given `{count}` naming the missed events while paused. The state belongs to the page, not to the block; on a page without `live` the block renders nothing.
|
|
131
131
|
|
|
132
132
|
```typescript
|
|
133
133
|
import { LiveToggle } from "@antelopejs/interface-dms/base/live-toggle";
|
|
134
134
|
|
|
135
135
|
static toolbar = HStack().child("spacer", Spacer()).child("live", LiveToggle());
|
|
136
136
|
```
|
|
137
|
+
|
|
138
|
+
## Page Toolbar
|
|
139
|
+
|
|
140
|
+
`PageToolbar(options?)` (subpath `page-toolbar`, block type `PageToolbar`, component `dms-page-toolbar-block`, group `data`, a container) is the context bar of a page with a period or live data, first in its body. `PageToolbarProps` extends `BaseComponentProps`: `size` (`PAGE_TOOLBAR_SIZES`: `xs`, `sm`, `md`; default `sm`), the size of the `LiveToggle` and `PeriodSelector` it holds unless they set one, and `showUpdatedAt` and `refresh` (both default `true`), the "updated N s ago" label and the refresh button it shows where no `LiveToggle` is on screen — a page without `live` — counted from the page's last read. Its children render in one order whatever order they were added in: the other blocks, a spacer, the updated label or the `LiveToggle`, then the `PeriodSelector`.
|
|
141
|
+
|
|
142
|
+
`pageToolbar(options?)` builds one: `PageToolbarOptions` extends `PageToolbarProps` with `leading` (`Record<string, Component>`, the blocks on the left by child id), `live` (`LiveToggleProps | false`, child `live`, over `PAGE_TOOLBAR_LIVE_DEFAULTS`) and `period` (`PeriodSelectorProps | false`, child `period`, over `PAGE_TOOLBAR_PERIOD_DEFAULTS`: segmented `last-hour` / `last-24h` / `last-7-days` / `last-30-days`, `defaultPreset: "last-24h"`, `rangeLabel: "relative"`, right aligned). Unless `period` sets `comparisonControl`, it is `"menu"` when the period offers more than one comparison (`none` included; the selector's default list when `comparisons` is unset), on `defaultComparison` or else the first one besides `none`, and `"hidden"` with a single comparison or none.
|
|
143
|
+
|
|
144
|
+
```typescript
|
|
145
|
+
import { pageToolbar } from "@antelopejs/interface-dms/base/page-toolbar";
|
|
146
|
+
|
|
147
|
+
static toolbar = pageToolbar({
|
|
148
|
+
leading: { scope: scopeChip },
|
|
149
|
+
live: { missedLabel: "$api.live.new_requests" },
|
|
150
|
+
period: { id: "api", rememberKey: "api", comparisons: ["previous-period"] },
|
|
151
|
+
});
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
A module's Vue component may also draw the toolbar itself, around controls of its own, as `<DmsPageToolbarBlock>` holding a `<DmsLiveToggleBlock>`: outside the page's block tree, neither needs a `componentId` or `pageId`.
|
package/docs/3.base/2.types.md
CHANGED
|
@@ -90,7 +90,7 @@ import {
|
|
|
90
90
|
|
|
91
91
|
`Size` maps readable names onto the scale tokens: `tiny` → `"xs"`, `small` → `"sm"`, `medium` → `"md"`, `large` → `"lg"`, `huge` → `"xl"`. `SizeValue` widens it the same way `ColorValue` widens `Color`.
|
|
92
92
|
|
|
93
|
-
`ModalSize` is a separate, wider union
|
|
93
|
+
`ModalSize` is a separate, wider union for modal and drawer action targets: `"sm" | "md" | "lg" | "xl" | "2xl" | "3xl" | "4xl" | "5xl" | "full"`: a modal's width, and the width of a side drawer given that size, `full` the whole window.
|
|
94
94
|
|
|
95
95
|
## Buttons
|
|
96
96
|
|
|
@@ -145,9 +145,9 @@ import type { ActionTarget } from "@antelopejs/interface-dms/base/types/action-t
|
|
|
145
145
|
|
|
146
146
|
| `type` | Fields |
|
|
147
147
|
| --- | --- |
|
|
148
|
-
| `"drawer"` | `component: Component`, `title?`, `description?`, `direction?: DrawerDirection` |
|
|
148
|
+
| `"drawer"` | `component: Component`, `title?`, `description?`, `direction?: DrawerDirection`, plus `DrawerTargetOptions`: `size?: ModalSize`, `rowHeader?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` |
|
|
149
149
|
| `"modal"` | `component: Component`, `size?: ModalSize`, `title?`, `description?` |
|
|
150
|
-
| `"sidePanel"` | `component: Component`, plus `SidePanelTargetOptions`: `title?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` |
|
|
150
|
+
| `"sidePanel"` | `component: Component`, plus `SidePanelTargetOptions`: `title?`, `rowTitle?`, `positionLabel?`, `deepLink?`, `notFound?` — for global, page-independent panels; a detail panel is a `drawer` |
|
|
151
151
|
| `"page"` | `url: string`: the page URL's `{{params.X}}` / `{{query.X}}` (a query pair without a value left out), then on a row action the row's `{{row.field}}`; see [Template tokens](/docs/building/pages-and-components#template-tokens) |
|
|
152
152
|
| `"external"` | `url: string`, `newTab?: boolean`; the same tokens as `page` |
|
|
153
153
|
| `"download"` | `url: string`, `filename?: string` (`DownloadActionTarget`); the same tokens as `page`, the file name taking them as written. Fetched with the user's session and saved under `filename`, else the server's `Content-Disposition` name, else the URL's last segment; a failure is toasted |
|
|
@@ -155,9 +155,11 @@ import type { ActionTarget } from "@antelopejs/interface-dms/base/types/action-t
|
|
|
155
155
|
| `"api"` | `url: string`, `method?: "GET" \| "POST" \| "PUT" \| "DELETE" \| "PATCH"`, `body?`, `copy?`, `successMessage: string`; the same tokens as `page` |
|
|
156
156
|
| `"exportJob"` | `url: string`, `method?: "GET" \| "POST"`, `statusUrl?`, `downloadUrl?`, `labels?`; the same tokens as `page`, plus `{{job.id}}` in `statusUrl` and `downloadUrl` |
|
|
157
157
|
|
|
158
|
-
`SidePanelTargetOptions` sets the header the DMS draws above a `sidePanel` target's component: `title` (the action's label by default; `$`: an i18n key), `rowTitle`, the title while a row is open, an i18n key or a literal receiving the row's fields (`"{method} {path}"`), `positionLabel`, an i18n key receiving `{ index }` (from 1) and `{ total }` for the row's position among the rows the table
|
|
158
|
+
`SidePanelTargetOptions` sets the header the DMS draws above a `sidePanel` target's component: `title` (the action's label by default; `$`: an i18n key), `rowTitle`, the title while a row is open, an i18n key or a literal receiving the row's fields (`"{method} {path}"`), `positionLabel`, an i18n key receiving `{ index }` (from 1) and `{ total }` for the row's position among all the rows the table lists, its count rather than the page loaded (the DMS's "{index} of {total}" by default), and `deepLink`, which keeps the open row in the URL like the action's own `deepLink` — on by default for a side panel, `false` opting out (the action's own `deepLink` wins when set), and `notFound` (`SidePanelNotFoundTexts`: `title?`, `description?`), the state the panel draws with a close button when the row a link names answers 404. The panel is non-modal and docked on the right, one at a time with a module's side panels. It is meant for global, page-independent panels (an assistant dock): a detail or record panel opens in a `drawer`.
|
|
159
159
|
|
|
160
|
-
`
|
|
160
|
+
`DrawerTargetOptions` gives a `drawer` target opened on a row the same options, so a detail panel moves from `sidePanel` to `drawer` without losing any: `rowTitle`, `positionLabel` and `notFound` as above; `rowHeader`, the DMS drawing the row header (the title naming the row, its position, previous and next buttons next to the close button) — on when `rowTitle` or `positionLabel` is set, `false` leaving the drawer's own title, `true` drawing it with the defaults; `deepLink`, off by default for a drawer (the action's own `deepLink` wins when set); and `size` (`ModalSize`), the width a `left` / `right` drawer opens at from `lg`, a modal's width at that size. J and K step through the rows in place whatever the header. A drawer shows its not-found state only once its target sets `notFound`; otherwise a deep link to a missing row is a toast, as before.
|
|
161
|
+
|
|
162
|
+
`DrawerDirection` is `"top" | "right" | "bottom" | "left"`, the edge the drawer slides in from: `bottom` by default, while `left` and `right` open a side sheet spanning the window's height, 600px wide from `lg` without a `size` (capped to the window) and 440px below it. From `lg` the user resizes every drawer by its inner edge (width or height), and the drawer opens again at the size it was left at.
|
|
161
163
|
|
|
162
164
|
A target carries no confirmation: the action does (`CustomButton.confirm`, `CustomRowAction.confirm`, `RowActionConfig.confirm`), with one type, `ActionConfirm = ConfirmDialog | ConfirmDialogFrom` (see below). The `labels` object on `exportJob` accepts `title`, `exporting`, `downloading`, `successTitle`, `successMessage`, `errorTitle` and `retry`, all optional strings.
|
|
163
165
|
|