@antelopejs/interface-dms 0.7.1 → 0.7.2
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 +56 -0
- package/README.md +70 -68
- package/dist/auth/db/models/sessions.model.d.ts +1 -0
- package/dist/auth/db/models/userExternalIdentities.model.d.ts +1 -0
- package/dist/auth/db/models/users.model.d.ts +1 -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/live-toggle.d.ts +2 -2
- package/dist/base/period-selector.d.ts +2 -2
- package/dist/base/table-view/schema.d.ts +4 -4
- package/dist/db/internal/exportJobs.model.d.ts +1 -0
- package/dist/db/internal/inviteResolutions.model.d.ts +1 -0
- package/dist/db/internal/tenantLifecycle.model.d.ts +1 -0
- package/dist/db/models/roles.model.d.ts +1 -0
- package/dist/db/models/tenantMembers.model.d.ts +1 -0
- package/dist/db/models/tenants.model.d.ts +1 -0
- package/dist/db/models/user_invites.model.d.ts +1 -0
- package/docs/.navigation.yml +2 -0
- package/docs/1.core/.navigation.yml +2 -0
- package/docs/1.core/1.introduction.md +169 -0
- package/docs/1.core/10.layout-banners.md +133 -0
- package/docs/1.core/2.pages-and-modules.md +647 -0
- package/docs/1.core/3.components.md +287 -0
- package/docs/1.core/4.permissions.md +279 -0
- package/docs/1.core/5.tenancy.md +356 -0
- package/docs/1.core/6.invites.md +239 -0
- package/docs/1.core/7.hooks.md +354 -0
- package/docs/1.core/8.realtime.md +147 -0
- package/docs/1.core/9.quick-actions.md +115 -0
- package/docs/2.auth/.navigation.yml +2 -0
- package/docs/2.auth/1.introduction.md +75 -0
- package/docs/2.auth/2.authentication.md +222 -0
- package/docs/2.auth/3.tokens-and-sessions.md +352 -0
- package/docs/2.auth/4.users.md +256 -0
- package/docs/2.auth/5.external-identities.md +122 -0
- package/docs/3.base/.navigation.yml +2 -0
- package/docs/3.base/1.introduction.md +202 -0
- package/docs/3.base/10.display-blocks.md +136 -0
- package/docs/3.base/2.types.md +402 -0
- package/docs/3.base/3.forms.md +349 -0
- package/docs/3.base/4.tables.md +608 -0
- package/docs/3.base/5.charts-and-widgets.md +466 -0
- package/docs/3.base/6.layout.md +431 -0
- package/docs/3.base/8.data-types.md +361 -0
- package/docs/3.base/9.helpers.md +414 -0
- package/docs/4.notifications/.navigation.yml +2 -0
- package/docs/4.notifications/1.introduction.md +79 -0
- package/docs/4.notifications/2.categories-and-subjects.md +212 -0
- package/docs/4.notifications/3.sending.md +283 -0
- package/docs/5.html-render/.navigation.yml +2 -0
- package/docs/5.html-render/1.introduction.md +372 -0
- package/docs/5.html-render/2.built-in-emails.md +107 -0
- package/docs/index.md +100 -0
- package/docs/tsconfig.json +11 -0
- package/package.json +24 -18
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Components"
|
|
3
|
+
description: "A component is a serializable description of a piece of UI, not a live object."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Components
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
A component is a serializable description of a piece of UI, not a live object. `ComponentBuilder` collects a component name, an options object, children, watches and actions; `serialize()` turns that into plain JSON-safe data; the page layout route returns the result. Nothing in this interface renders anything.
|
|
11
|
+
|
|
12
|
+
Everything on this page lives in `@antelopejs/interface-dms/component`. The [Base](/docs/interfaces/base/introduction) domain ships the concrete builders (`Form`, `TableView`, `Grid`, the chart family) built on top of these types; use this page when you write a builder of your own or when you need to know what a serialized component looks like.
|
|
13
|
+
|
|
14
|
+
## The Serialize-Then-Render Contract
|
|
15
|
+
|
|
16
|
+
Two shapes describe the same component on the two sides of serialization:
|
|
17
|
+
|
|
18
|
+
```typescript
|
|
19
|
+
interface ComponentInfo<T = unknown, C = Component<T>, ChildType = ComponentChild<T, C>> {
|
|
20
|
+
componentName: string;
|
|
21
|
+
options?: T;
|
|
22
|
+
children?: ChildType[];
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ComponentInfoSerialized<T = unknown> = Omit<ComponentInfo<T>, "children"> & {
|
|
26
|
+
children?: ChildSerialized[];
|
|
27
|
+
};
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
`ComponentInfo` is what a builder holds: `children` carry live `Component` instances. `ComponentInfoSerialized` is what the page layout carries: every child has been replaced by its own serialized description.
|
|
31
|
+
|
|
32
|
+
| Type | Shape |
|
|
33
|
+
| ---- | ----- |
|
|
34
|
+
| `ComponentChild<T, C>` | `{ id: string; component: C; slot?: string; [key: string]: unknown }` |
|
|
35
|
+
| `BaseComponentChild` | `ComponentChild` with `component` fixed to `Component` |
|
|
36
|
+
| `ChildSerialized` | `{ id: string; component: ComponentInfoSerialized; slot?: string; [key: string]: unknown }` |
|
|
37
|
+
| `ComponentInfoPromise<T>` | `MaybePromise<ComponentInfo<T, Component<T>, BaseComponentChild>>` |
|
|
38
|
+
| `ComponentMetadata` | `{ name: string; description?: string; icon?: string }` |
|
|
39
|
+
|
|
40
|
+
The extra index signature on a child is the escape hatch for per-child props a container needs (a grid column span, a tab label): whatever you pass in the child metadata survives serialization next to `id`, `component` and `slot`.
|
|
41
|
+
|
|
42
|
+
## Build a Component
|
|
43
|
+
|
|
44
|
+
`ComponentBuilder<T>` is the class you instantiate with the frontend component name. Every method returns `this`, so calls chain.
|
|
45
|
+
|
|
46
|
+
```typescript
|
|
47
|
+
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
|
|
48
|
+
|
|
49
|
+
interface CounterOptions {
|
|
50
|
+
title: string;
|
|
51
|
+
fetchUrl: string;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function Counter(options: CounterOptions) {
|
|
55
|
+
return new ComponentBuilder<CounterOptions>("acme-counter")
|
|
56
|
+
.options(options)
|
|
57
|
+
.meta({ name: options.title, icon: "i-ph-hash" });
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Assign the result to a static field of a page class and `@RegisterPage()` picks it up ([Pages and Modules](/docs/interfaces/core/pages-and-modules)).
|
|
62
|
+
|
|
63
|
+
| Method | Description |
|
|
64
|
+
| ------ | ----------- |
|
|
65
|
+
| `options(opts?)` | Replace the options object. A `watchActions` key on the input is stripped: watches are declared with `watch`/`watchOn` only. |
|
|
66
|
+
| `mergeOptions(opts)` | Shallow-merge into the existing options. Logs a warning and does nothing when no options were set. |
|
|
67
|
+
| `child(id, component, metadata?)` | Append a child with its own positional permission. `metadata` may carry `slot` plus any additional props. A duplicate `id` logs a warning. |
|
|
68
|
+
| `meta(meta)` | Merge into the component metadata (`name`, `description`, `icon`). |
|
|
69
|
+
| `onCreated(callback)` | Run `callback(pageMetadata)` when the component's page registers. |
|
|
70
|
+
| `onFilter(callback)` | Transform the options per caller, from their permission set. |
|
|
71
|
+
| `watch(event, functionId, opts?)` | Declare a watch on this component. |
|
|
72
|
+
| `watchOn(childId, event, functionId, opts?)` | Declare a watch spliced into a child's options at serialization time. |
|
|
73
|
+
| `watchFilter(callback)` | Filter the accumulated watches per caller. |
|
|
74
|
+
| `action(id, definition)` | Declare an `Action` on the component. |
|
|
75
|
+
| `button(id, { permission? })` | Register a named button a quick action can press, with the permission (an action id of this component, or any `Action`) that gates it. A duplicate `id` logs a warning. The table view registers its `customButtons` that carry an `id`. |
|
|
76
|
+
| `navBadge({ count, page? })` | Publish a count as the navigation badge of a page (`NavBadgeSource`): `count(ctx, user)` answers it for the caller on each page visit — a number, drawn neutral, or `{ count, tone }` ([Navigation badges](#navigation-badges)) — and throws to publish nothing for a caller who may not read it. `page` names the page controller the badge goes to, the component's own page by default. A table view's tab declared with `navBadge` publishes through it; a component of your own calls it directly (the DMS's notifications, security and roles pages do). A count of zero shows no badge, and a page's static `MenuOptions.badge` wins. |
|
|
77
|
+
| `before(anchor)` | Place the component above `anchor` (a component key of the target page) when injecting it into another page. |
|
|
78
|
+
| `after(anchor)` | Place it below `anchor`. |
|
|
79
|
+
| `order(value)` | Break ties between components landing at the same spot. Defaults to 0. |
|
|
80
|
+
|
|
81
|
+
### Navigation badges
|
|
82
|
+
|
|
83
|
+
`count` answers a number, drawn as a neutral grey pill, or a `NavBadgeCount` that also gives the tone it is drawn in, with the colours of the dashboard's status pills:
|
|
84
|
+
|
|
85
|
+
```typescript
|
|
86
|
+
interface NavBadgeCount {
|
|
87
|
+
count: number;
|
|
88
|
+
tone?: Tone; // from "@antelopejs/interface-dms/base/types/tone"
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
component.navBadge({
|
|
92
|
+
count: async (_ctx, user) => {
|
|
93
|
+
const [overdue, dueSoon] = await countInvoices(user._id);
|
|
94
|
+
return overdue > 0
|
|
95
|
+
? { count: overdue + dueSoon, tone: "error" }
|
|
96
|
+
: { count: dueSoon, tone: "warning" };
|
|
97
|
+
},
|
|
98
|
+
});
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Pick the tone on the grid the DMS's own notifications follow: `error` for an immediate risk, `warning` for something to check, `primary` for an action available, `success` for good news, `neutral` (or no tone) for plain information; `secondary` and `info` are accepted too. When several components publish a badge for one page, their counts add up and the badge takes the strongest of their tones, in this order: `error`, `warning`, `primary`, `secondary`, `success`, `info`, `neutral`. Each page visit serves the counts as `navBadges`, `{ badge, tone? }` by page full id, next to the page and never in the site layout a browser keeps for minutes.
|
|
102
|
+
|
|
103
|
+
### Placement
|
|
104
|
+
|
|
105
|
+
The last three methods only mean something on a component declared inside a `@RegisterPageExtension` class; on a component declared on its own page they are ignored. Together they build a `ComponentPlacement`:
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
type PlacementSide = "before" | "after" | "end";
|
|
109
|
+
|
|
110
|
+
interface ComponentPlacement {
|
|
111
|
+
side: PlacementSide;
|
|
112
|
+
anchor?: readonly string[];
|
|
113
|
+
order: number;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const DEFAULT_PLACEMENT: ComponentPlacement = { side: "end", order: 0 };
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
`anchor` is a component key of the target page: the name of the static field it declared the component under, passed as `.before("table")`, or a dotted path to a nested position, `.before("content.tasks")` — stored as the split path. The target page is never imported, so the key is a string. A component that declares no placement gets `DEFAULT_PLACEMENT` — appended after every component of the target page, with no ordering hint. Read the current value off `component.placement`, which is `undefined` until one of the three methods is called.
|
|
120
|
+
|
|
121
|
+
Lower `order` comes first, and equal orders fall back to the extension class name and then to declaration order, so the result never depends on module start order. [Pages and Modules](/docs/interfaces/core/pages-and-modules) covers the extension mechanism these describe placement for.
|
|
122
|
+
|
|
123
|
+
### Children and Slots
|
|
124
|
+
|
|
125
|
+
```typescript
|
|
126
|
+
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
|
|
127
|
+
import { Placeholder } from "@antelopejs/interface-dms/base";
|
|
128
|
+
|
|
129
|
+
const panel = new ComponentBuilder<{ title: string }>("acme-panel")
|
|
130
|
+
.options({ title: "Overview" })
|
|
131
|
+
.child("header", Placeholder({ label: "Header" }), { slot: "header" })
|
|
132
|
+
.child("body", Placeholder({ label: "Body" }), { slot: "default", grow: true });
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
The `grow: true` above is an arbitrary extra prop: it lands on the serialized child alongside `id`, `component` and `slot`.
|
|
136
|
+
|
|
137
|
+
## The `Component` Class
|
|
138
|
+
|
|
139
|
+
`ComponentBuilder` extends `Component<T>`, which is also usable directly when you already hold a `ComponentInfo`:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
new Component<T>(
|
|
143
|
+
componentInfo: ComponentInfoPromise<T>,
|
|
144
|
+
metadata: ComponentMetadata,
|
|
145
|
+
onPageCreated?: (page: PageMetadata) => void,
|
|
146
|
+
)
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`PageMetadata` is the page record described in [Pages and Modules](/docs/interfaces/core/pages-and-modules).
|
|
150
|
+
|
|
151
|
+
| Member | Description |
|
|
152
|
+
| ------ | ----------- |
|
|
153
|
+
| `componentInfo` | The `ComponentInfo`, possibly a promise. |
|
|
154
|
+
| `metadata` | The `ComponentMetadata`. |
|
|
155
|
+
| `actions` | `Record<string, Action>` of the declared actions. |
|
|
156
|
+
| `getAction(id)` | One action, or `undefined`. |
|
|
157
|
+
| `getButton(id)` | One registered button (`ComponentButton`), or `undefined`. |
|
|
158
|
+
| `resolveButtonPermissionId(permission)` | The permission id a button's `permission` stands for on this component, or `undefined` when it cannot be resolved (callers treat that as refused). |
|
|
159
|
+
| `onPageCreated` | The callback run when the owning page registers. |
|
|
160
|
+
| `onFilterCallback` | The per-caller options filter, when one is installed. |
|
|
161
|
+
| `placement` | The `ComponentPlacement`, or `undefined` when none was declared. |
|
|
162
|
+
| `serialize()` | `Promise<ComponentInfoSerialized<T>>` — awaits the component info and serializes every child. |
|
|
163
|
+
| `serializeSync()` | The same, for a component whose info is not a promise. |
|
|
164
|
+
|
|
165
|
+
Both serialize methods normalize `children` to `[]` when the component has none.
|
|
166
|
+
|
|
167
|
+
Because `componentInfo` may be a promise, a builder can resolve data at registration time. Use `serialize()` unless you know the info is synchronous.
|
|
168
|
+
|
|
169
|
+
## Actions
|
|
170
|
+
|
|
171
|
+
An action is a named capability of a component that carries its own permission — "add a row", "export", "restore". Declare one on the builder:
|
|
172
|
+
|
|
173
|
+
```typescript
|
|
174
|
+
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
|
|
175
|
+
|
|
176
|
+
const table = new ComponentBuilder<{ fetchUrl: string }>("acme-table")
|
|
177
|
+
.options({ fetchUrl: "/api/things" })
|
|
178
|
+
.action("export", {
|
|
179
|
+
title: "$acme.table.action_export",
|
|
180
|
+
icon: "i-ph-download-simple",
|
|
181
|
+
description: "$acme.table.action_export_description",
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const exportAction = table.getAction("export");
|
|
185
|
+
```
|
|
186
|
+
|
|
187
|
+
`ActionDefinition` carries `title` (required), and optional `icon`, `description` and `defaultGranted`.
|
|
188
|
+
|
|
189
|
+
An `Action` instance exposes:
|
|
190
|
+
|
|
191
|
+
| Member | Description |
|
|
192
|
+
| ------ | ----------- |
|
|
193
|
+
| `id` | The action id, as passed to `action()`. |
|
|
194
|
+
| `definition` | The `ActionDefinition`. |
|
|
195
|
+
| `permissionId` | The component's permission id with `.<id>` appended, or `undefined` while the component has no permission id yet. |
|
|
196
|
+
| `toPermission()` | A `Permission` built from the definition, or `undefined` when `permissionId` is. |
|
|
197
|
+
|
|
198
|
+
The component's permission id only exists once the owning page has registered, so read `permissionId` from `onCreated` or later — not while the builder is still being assembled.
|
|
199
|
+
|
|
200
|
+
An `Action` is accepted anywhere a permission is: as the `permission` option of a page ([Pages and Modules](/docs/interfaces/core/pages-and-modules)) and as the target of `AuthUserWithPermission` ([Permissions](/docs/interfaces/core/permissions)).
|
|
201
|
+
|
|
202
|
+
`getPermissionIdRef` is the seam that lets `Action` resolve its component's permission id without importing the page module: it is an object with a single `get(component)` method, replaced by `dms/page` when that module loads. Consumers read `permissionId`; there is no reason to call or reassign the ref.
|
|
203
|
+
|
|
204
|
+
## Per-Caller Filtering
|
|
205
|
+
|
|
206
|
+
`onFilter` rewrites a component's options for the caller who requested the page layout. It runs during layout resolution, after the component itself passed its permission check.
|
|
207
|
+
|
|
208
|
+
```typescript
|
|
209
|
+
import { ComponentBuilder } from "@antelopejs/interface-dms/component";
|
|
210
|
+
|
|
211
|
+
interface ListOptions {
|
|
212
|
+
fetchUrl: string;
|
|
213
|
+
allowBulkDelete: boolean;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const list = new ComponentBuilder<ListOptions>("acme-list")
|
|
217
|
+
.options({ fetchUrl: "/api/things", allowBulkDelete: true })
|
|
218
|
+
.onFilter((permissions, options) => ({
|
|
219
|
+
...options,
|
|
220
|
+
allowBulkDelete: permissions.has("things.delete"),
|
|
221
|
+
}));
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
The callback receives `(permissions: Set<string>, options: T, permissionId: string, context: ComponentFilterContext)` and returns the options — synchronously or as a promise. The third argument is the component position's own permission id, including the full parent path for a child, so a filter can derive the ids of the actions it needs to test. The fourth is the request the layout is built for: its `tenantId`, its `user` (undefined when unauthenticated), and `query`, the page URL's query when the request carries one (what a header button's `availability` reads; check its values like any input).
|
|
225
|
+
|
|
226
|
+
## Watches
|
|
227
|
+
|
|
228
|
+
A watch binds an event on a component to a registered function. The backend only describes the binding.
|
|
229
|
+
|
|
230
|
+
```typescript
|
|
231
|
+
watch<F extends string>(event: string, functionId: F, opts?: WatchOptions<F>): this
|
|
232
|
+
watchOn<F extends string>(childId: string, event: string, functionId: F, opts?: WatchOptions<F>): this
|
|
233
|
+
```
|
|
234
|
+
|
|
235
|
+
| `WatchOptions` field | Type | Description |
|
|
236
|
+
| -------------------- | ---- | ----------- |
|
|
237
|
+
| `params` | `WatchFunctionParams<F>` | Arguments handed to the function. Typed per `functionId` when the function declares its parameter map. |
|
|
238
|
+
| `onParam` | `WatchActionCondition \| WatchActionCondition[]` | Conditions on the event payload, each `{ key, value }`. |
|
|
239
|
+
| `requirePermission` | `string` | Permission id the caller must hold; the watch is dropped from the layout otherwise. |
|
|
240
|
+
|
|
241
|
+
`watch` targets the component itself; `watchOn(childId, ...)` targets a child added with `child()` and is spliced into that child's serialized options. Calling `watchOn` with an unknown child id logs a warning.
|
|
242
|
+
|
|
243
|
+
```typescript
|
|
244
|
+
import {
|
|
245
|
+
Form,
|
|
246
|
+
FormEvents,
|
|
247
|
+
FormFunctions,
|
|
248
|
+
} from "@antelopejs/interface-dms/base/form";
|
|
249
|
+
|
|
250
|
+
const form = Form({ title: "Profile", fields: [] }).watch(
|
|
251
|
+
FormEvents.FIELD_CHANGE,
|
|
252
|
+
FormFunctions.SET_FIELD_DISABLED,
|
|
253
|
+
{
|
|
254
|
+
params: { targetField: "adminNotes", setDisabled: true },
|
|
255
|
+
onParam: [
|
|
256
|
+
{ key: "fieldId", value: "isOwner" },
|
|
257
|
+
{ key: "value", value: false },
|
|
258
|
+
],
|
|
259
|
+
requirePermission: "pages.crm.contacts.form",
|
|
260
|
+
},
|
|
261
|
+
);
|
|
262
|
+
```
|
|
263
|
+
|
|
264
|
+
`watchFilter(callback)` narrows the watch list per caller: it receives `(permissions, watches, permissionId)` and returns the watches to keep. It runs right after `onFilter` in the same filtering pass, and the `requirePermission` narrowing is applied to whatever it returns.
|
|
265
|
+
|
|
266
|
+
## Component Events
|
|
267
|
+
|
|
268
|
+
`ComponentEvents` holds the lifecycle event names:
|
|
269
|
+
|
|
270
|
+
| Constant | Value |
|
|
271
|
+
| -------- | ----- |
|
|
272
|
+
| `ComponentEvents.LOAD` | `"DmsComponent.Load"` |
|
|
273
|
+
| `ComponentEvents.UNLOAD` | `"DmsComponent.Unload"` |
|
|
274
|
+
|
|
275
|
+
Use them as the `event` argument of `watch` or `watchOn`, or as the event name in a page setup function's `on()` subscription ([Pages and Modules](/docs/interfaces/core/pages-and-modules)).
|
|
276
|
+
|
|
277
|
+
## `RegisterComponentSlot`
|
|
278
|
+
|
|
279
|
+
`RegisterComponentSlot(slotId: string, resolver: ComponentSlotResolver): () => void`, from `@antelopejs/interface-dms/component-slots`, claims a component slot. The resolver receives the options of each component declaring that `slotId`, once per layout request, and returns the replacement options. Returning the input unchanged contributes nothing.
|
|
280
|
+
|
|
281
|
+
`SlotOptions` is `Record<string, unknown>`; `ComponentSlotResolver` is `(options: SlotOptions) => MaybePromise<SlotOptions>`. The latest claim for an id wins. The returned release function removes the claim only if that resolver still owns it, so releasing an older claim does not remove a newer one.
|
|
282
|
+
|
|
283
|
+
Use the returned function when the claiming module stops. [TableView form slots](/docs/components/tables#tableview-options) show how a controller exposes a slot; the controller still owns validation and persistence of contributed fields.
|
|
284
|
+
|
|
285
|
+
## Next Steps
|
|
286
|
+
|
|
287
|
+
[Permissions](/docs/interfaces/core/permissions) covers the ids these components and actions register, and the guards that check them.
|
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
---
|
|
2
|
+
title: "Permissions"
|
|
3
|
+
description: "Permission ids are hierarchical strings built from the page tree: a page's `fullId`, then every component id in the position's path, then the action id when present."
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Permissions
|
|
7
|
+
|
|
8
|
+
## Overview
|
|
9
|
+
|
|
10
|
+
Permission ids are hierarchical strings built from the page tree: a page's `fullId`, then every component id in the position's path, then the action id when present. A role holds a flat list of granted ids; a platform owner holds the wildcard `*`. Two functions answer "what can this user do" — `GetUserPermissions` returns the base set from roles, `GetEffectiveUserPermissions` runs that set through the registered resolvers. Authorization surfaces use the effective set.
|
|
11
|
+
|
|
12
|
+
The surface spans three subpaths:
|
|
13
|
+
|
|
14
|
+
| Subpath | Contains |
|
|
15
|
+
| ------- | -------- |
|
|
16
|
+
| `@antelopejs/interface-dms/permissions` | The registry, the module-scope marks, and the permission computation |
|
|
17
|
+
| `@antelopejs/interface-dms/permissions-resolver` | Registering and running resolvers |
|
|
18
|
+
| `@antelopejs/interface-dms/guards` | Route guards |
|
|
19
|
+
|
|
20
|
+
## Register a Permission
|
|
21
|
+
|
|
22
|
+
Pages, components and actions register their own permissions when the page registers ([Pages and Modules](/docs/interfaces/core/pages-and-modules)). Register a standalone one when a capability has no component behind it:
|
|
23
|
+
|
|
24
|
+
```typescript
|
|
25
|
+
import { RegisterPermission } from "@antelopejs/interface-dms/permissions";
|
|
26
|
+
|
|
27
|
+
RegisterPermission("reports.export", {
|
|
28
|
+
id: "reports.export",
|
|
29
|
+
title: "$my_app.perm.reports_export",
|
|
30
|
+
icon: "i-ph-download",
|
|
31
|
+
description: "$my_app.perm.reports_export_description",
|
|
32
|
+
dependencies: ["reports.view"],
|
|
33
|
+
defaultGranted: false,
|
|
34
|
+
});
|
|
35
|
+
```
|
|
36
|
+
|
|
37
|
+
### `Permission`
|
|
38
|
+
|
|
39
|
+
| Field | Type | Description |
|
|
40
|
+
| ----- | ---- | ----------- |
|
|
41
|
+
| `id` | `string` | The permission id. Required. |
|
|
42
|
+
| `title` | `string` | Label shown in the roles editor. Required. |
|
|
43
|
+
| `icon` | `string` | Icon name. |
|
|
44
|
+
| `description` | `string` | Longer description. |
|
|
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
|
+
| `defaultGranted` | `boolean` | Treat the permission as held by every caller, without a role grant. |
|
|
47
|
+
|
|
48
|
+
`RegisterPermission(id, permission)` takes the id twice — once as the registry key, once inside the record — so keep them equal.
|
|
49
|
+
|
|
50
|
+
`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.
|
|
51
|
+
|
|
52
|
+
## Read the Registry
|
|
53
|
+
|
|
54
|
+
```typescript
|
|
55
|
+
import {
|
|
56
|
+
GetPermission,
|
|
57
|
+
GetPermissions,
|
|
58
|
+
} from "@antelopejs/interface-dms/permissions";
|
|
59
|
+
|
|
60
|
+
const permission = await GetPermission("reports.export");
|
|
61
|
+
const tree = await GetPermissions();
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
`GetPermission(id)` resolves to the `Permission` or `undefined`. `GetPermissions()` resolves to a `Record<string, PermissionTree>` keyed by the first id segment, where
|
|
65
|
+
|
|
66
|
+
```typescript
|
|
67
|
+
type PermissionTree = {
|
|
68
|
+
data?: Permission;
|
|
69
|
+
children: Record<string, PermissionTree>;
|
|
70
|
+
};
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
Each level keys its children by the next dot-separated segment. `data` is absent for a level that exists only to hold children.
|
|
74
|
+
|
|
75
|
+
Both are interface functions and return promises even though the registry is in-process.
|
|
76
|
+
|
|
77
|
+
## Compute a User's Permissions
|
|
78
|
+
|
|
79
|
+
```typescript
|
|
80
|
+
import {
|
|
81
|
+
GetEffectiveUserPermissions,
|
|
82
|
+
GetUserPermissions,
|
|
83
|
+
HasPermission,
|
|
84
|
+
} from "@antelopejs/interface-dms/permissions";
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
| Function | Signature |
|
|
88
|
+
| -------- | --------- |
|
|
89
|
+
| `GetUserPermissions` | `(user: { owner?: boolean }, roleIds: string[], roleModel: RoleModel) => Promise<Set<string>>` |
|
|
90
|
+
| `GetEffectiveUserPermissions` | `(user: { _id: string; owner?: boolean }, tenantId: string, roleIds: string[], roleModel: RoleModel) => Promise<Set<string>>` |
|
|
91
|
+
| `HasPermission` | `(permissions: Set<string>, permissionId: string) => Promise<boolean>` |
|
|
92
|
+
|
|
93
|
+
`GetUserPermissions` returns `Set(["*"])` for a user with `owner: true`, an empty set when `roleIds` is empty, and otherwise the union of the `permissions` arrays of the roles it loads through `roleModel`. This is the **base** set: it does not account for resolvers, so do not authorize against it.
|
|
94
|
+
|
|
95
|
+
`GetEffectiveUserPermissions` composes that base set with `ApplyPermissionsResolvers` and is the set every authorization surface uses.
|
|
96
|
+
|
|
97
|
+
`HasPermission` resolves in this order:
|
|
98
|
+
|
|
99
|
+
1. `true` when the set holds the wildcard `*`.
|
|
100
|
+
2. `false` when the id is module-scoped.
|
|
101
|
+
3. `true` when the registered permission carries `defaultGranted`.
|
|
102
|
+
4. `false` when the set does not hold the id.
|
|
103
|
+
5. `true` when the set also holds every id the permission sits under (`pages.sales` and `pages` for `pages.sales.orders`), `false` otherwise. An id above it that is not a registered permission, or that carries `defaultGranted`, counts as held.
|
|
104
|
+
|
|
105
|
+
The wildcard is checked first, so a platform owner passes even for a module-scoped id. Step 5 makes a grant worth nothing without its parents: a table's `delete` action is refused to a role that holds neither the table nor its page. The roles editor and the role routes store a grant with every id above it, so only a role saved before they did, or a set a resolver reshaped, can miss one.
|
|
106
|
+
|
|
107
|
+
```typescript
|
|
108
|
+
import { GetModel } from "@antelopejs/interface-database-decorators";
|
|
109
|
+
import {
|
|
110
|
+
RoleModel,
|
|
111
|
+
TenantMemberModel,
|
|
112
|
+
} from "@antelopejs/interface-dms/db";
|
|
113
|
+
import {
|
|
114
|
+
GetEffectiveUserPermissions,
|
|
115
|
+
HasPermission,
|
|
116
|
+
} from "@antelopejs/interface-dms/permissions";
|
|
117
|
+
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
118
|
+
|
|
119
|
+
async function canExport(user: User, tenantId: string): Promise<boolean> {
|
|
120
|
+
const member = await GetModel(TenantMemberModel, tenantId).getByUser(user._id);
|
|
121
|
+
const permissions = await GetEffectiveUserPermissions(
|
|
122
|
+
user,
|
|
123
|
+
tenantId,
|
|
124
|
+
member?.roleIds ?? [],
|
|
125
|
+
GetModel(RoleModel, tenantId),
|
|
126
|
+
);
|
|
127
|
+
return HasPermission(permissions, "reports.export");
|
|
128
|
+
}
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
`RoleModel` and `TenantMemberModel` come from [Tenancy](/docs/interfaces/core/tenancy).
|
|
132
|
+
|
|
133
|
+
## Module-Scoped Permissions
|
|
134
|
+
|
|
135
|
+
Permissions belonging to a module page or category are never added to the grantable permission tree, and a role grant on such an id never grants anything. They are marked instead:
|
|
136
|
+
|
|
137
|
+
```typescript
|
|
138
|
+
import {
|
|
139
|
+
IsModuleScopedPermission,
|
|
140
|
+
MarkModuleScopedPermission,
|
|
141
|
+
UnmarkModuleScopedPermission,
|
|
142
|
+
} from "@antelopejs/interface-dms/permissions";
|
|
143
|
+
|
|
144
|
+
MarkModuleScopedPermission("modules.automation.builder");
|
|
145
|
+
IsModuleScopedPermission("modules.automation.builder.canvas.add"); // true
|
|
146
|
+
UnmarkModuleScopedPermission("modules.automation.builder");
|
|
147
|
+
```
|
|
148
|
+
|
|
149
|
+
`IsModuleScopedPermission(id)` walks the id upward one dot-separated segment at a time, so marking a page id also covers every component and action id derived from it. `UnmarkModuleScopedPermission(id)` removes the mark for that exact id only.
|
|
150
|
+
|
|
151
|
+
Page registration calls `MarkModuleScopedPermission` for you whenever `isInsideModule` holds ([Pages and Modules](/docs/interfaces/core/pages-and-modules)); call it directly only for ids you build outside that path.
|
|
152
|
+
|
|
153
|
+
## Reshape the Effective Set
|
|
154
|
+
|
|
155
|
+
A permissions resolver receives a caller's current permission set for a tenant and returns the set to use instead.
|
|
156
|
+
|
|
157
|
+
```typescript
|
|
158
|
+
import { RegisterPermissionsResolver } from "@antelopejs/interface-dms/permissions-resolver";
|
|
159
|
+
|
|
160
|
+
export async function construct(): Promise<void> {
|
|
161
|
+
RegisterPermissionsResolver({
|
|
162
|
+
id: "my_app.plan-limits",
|
|
163
|
+
order: 100,
|
|
164
|
+
resolver: async (userId, tenantId, currentPermissions) => {
|
|
165
|
+
const allowed = await loadPlanPermissions(tenantId);
|
|
166
|
+
return new Set([...currentPermissions].filter((p) => allowed.has(p)));
|
|
167
|
+
},
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
```typescript
|
|
173
|
+
type PermissionsResolverFn = (
|
|
174
|
+
userId: string,
|
|
175
|
+
tenantId: string,
|
|
176
|
+
currentPermissions: Set<string>,
|
|
177
|
+
) => Promise<Set<string>> | Set<string>;
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
`PermissionsResolverInfo` carries `id`, `resolver` and `order`.
|
|
181
|
+
|
|
182
|
+
Semantics the interface guarantees:
|
|
183
|
+
|
|
184
|
+
- Resolvers run in ascending `order`, each seeing the previous one's result; equal `order` runs in registration order, which is module load order.
|
|
185
|
+
- Registering an id twice replaces the earlier resolver rather than adding a second one.
|
|
186
|
+
- A resolver that throws aborts the computation and fails the request. It is never skipped.
|
|
187
|
+
- The registration is removed when the registering module unloads; there is no unregister call.
|
|
188
|
+
- Register in `construct()` so the resolver is active before the API serves requests.
|
|
189
|
+
|
|
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.
|
|
191
|
+
|
|
192
|
+
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
|
+
|
|
194
|
+
## Guard a Route
|
|
195
|
+
|
|
196
|
+
Three decorators from `@antelopejs/interface-dms/guards` gate a route on the caller.
|
|
197
|
+
|
|
198
|
+
### `AuthUserWithPermission`
|
|
199
|
+
|
|
200
|
+
`AuthUserWithPermission(target, options?)` requires an authenticated user holding the exact registered permission of `target`, which is a `Component`, a page controller class, or an `Action` ([Components](/docs/interfaces/core/components)). A child component resolves to its full positional path. `options` is the same `TenantGuardOptions` the two tenant guards take, described below.
|
|
201
|
+
|
|
202
|
+
```typescript
|
|
203
|
+
import { Controller, Get } from "@antelopejs/interface-api";
|
|
204
|
+
import { AuthUserWithPermission } from "@antelopejs/interface-dms/guards";
|
|
205
|
+
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
206
|
+
import { InvoicesPage } from "../pages/invoices";
|
|
207
|
+
|
|
208
|
+
export class InvoiceReportController extends Controller("/api/invoices") {
|
|
209
|
+
@Get("/summary")
|
|
210
|
+
async summary(@AuthUserWithPermission(InvoicesPage) _user: User) {
|
|
211
|
+
// reachable only by a caller holding the InvoicesPage permission
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
The guard resolves the target's permission id, loads the caller's membership in the tenant carried by the token, computes their effective permissions and calls `HasPermission`. It answers `403` when the permission is missing, and also when the token carries no tenant claim — a permission can only be granted inside a tenant. A target that carries no permission id yet passes the authentication check only. The tenant access gate runs whenever the token carries a tenant claim, including for a target with no permission id, and it runs before the permission is resolved — unless `options.bypassTenantAccessGate` is set, which skips the gate while leaving the permission check in place.
|
|
217
|
+
|
|
218
|
+
### `AuthTenantOwner` and `AuthTenantMember`
|
|
219
|
+
|
|
220
|
+
Both resolve the tenant from the request's JWT `tenantId` claim and check the caller's membership in it. `AuthTenantOwner` additionally requires `isTenantOwner`. Each answers `401` without a token and `403` when the requirement is not met, and each runs the tenant access gate before returning the user.
|
|
221
|
+
|
|
222
|
+
```typescript
|
|
223
|
+
import { Controller, Post } from "@antelopejs/interface-api";
|
|
224
|
+
import {
|
|
225
|
+
AuthTenantMember,
|
|
226
|
+
AuthTenantOwner,
|
|
227
|
+
} from "@antelopejs/interface-dms/guards";
|
|
228
|
+
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
229
|
+
|
|
230
|
+
export class BillingController extends Controller("/api/billing") {
|
|
231
|
+
@Post("/portal-session")
|
|
232
|
+
async portalSession(
|
|
233
|
+
@AuthTenantOwner({ bypassTenantAccessGate: true }) user: User,
|
|
234
|
+
) {
|
|
235
|
+
// stays reachable for a tenant a gate denies
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@Post("/usage")
|
|
239
|
+
async usage(@AuthTenantMember() user: User) {
|
|
240
|
+
// any member of the request's tenant
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
```
|
|
244
|
+
|
|
245
|
+
`TenantGuardOptions` has one field, and all three guards accept it:
|
|
246
|
+
|
|
247
|
+
| Option | Type | Description |
|
|
248
|
+
| ------ | ---- | ----------- |
|
|
249
|
+
| `bypassTenantAccessGate` | `boolean` | Skip the tenant access gate for this route. Reserved for routes that must stay reachable when a gate denies the tenant. |
|
|
250
|
+
|
|
251
|
+
The opt-out has two mirrors elsewhere, for the surfaces a route guard does not cover: `MenuOptions.bypassTenantAccessGate` keeps a page and its menu entry reachable ([Pages and Modules](/docs/interfaces/core/pages-and-modules)), and `TableViewOptions.bypassTenantAccessGate` covers a table view's data routes, which belong to a controller rather than to any one page.
|
|
252
|
+
|
|
253
|
+
A denial from a gate surfaces as a `403` whose body is the gate's `code` ([Tenancy](/docs/interfaces/core/tenancy)).
|
|
254
|
+
|
|
255
|
+
### Guards as Properties
|
|
256
|
+
|
|
257
|
+
All three are parameter *and* property decorators. Declared as a class property, a guard gates every route on that controller:
|
|
258
|
+
|
|
259
|
+
```typescript
|
|
260
|
+
import { Controller, Post } from "@antelopejs/interface-api";
|
|
261
|
+
import { AuthTenantMember } from "@antelopejs/interface-dms/guards";
|
|
262
|
+
import type { User } from "@antelopejs/interface-dms/auth/db";
|
|
263
|
+
|
|
264
|
+
export class FilesController extends Controller("/api/files") {
|
|
265
|
+
@AuthTenantMember()
|
|
266
|
+
declare user: User;
|
|
267
|
+
|
|
268
|
+
@Post("/presign")
|
|
269
|
+
async presign() {
|
|
270
|
+
// the guard already ran, whether or not this handler reads `this.user`
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
A property provider runs on every request to a route of the controller, independently of whether the handler body reads the property. That is what makes the property form a guard rather than a convenience: removing the read does not remove the check.
|
|
276
|
+
|
|
277
|
+
## Next Steps
|
|
278
|
+
|
|
279
|
+
[Tenancy](/docs/interfaces/core/tenancy) covers the tenant the guards resolve, the roles the permission sets come from, and the access gates.
|