@zerotal/admin 1.0.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 +69 -0
- package/LICENSE +21 -0
- package/README.md +344 -0
- package/package.json +78 -0
- package/src/Cluster.ts +50 -0
- package/src/Panel.ts +288 -0
- package/src/PanelInstance.ts +644 -0
- package/src/Resource.ts +918 -0
- package/src/actions/Action.ts +607 -0
- package/src/actions/ImportRecordsJob.ts +108 -0
- package/src/actions/csv.ts +123 -0
- package/src/actions/index.ts +39 -0
- package/src/actions/render.tsx +181 -0
- package/src/actions/transfer.ts +307 -0
- package/src/actions/xlsx.ts +304 -0
- package/src/auth/AuthLayout.tsx +34 -0
- package/src/auth/index.ts +13 -0
- package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
- package/src/auth/pages/LoginPage.tsx +121 -0
- package/src/auth/pages/ProfilePage.tsx +216 -0
- package/src/auth/pages/ResetPasswordPage.tsx +103 -0
- package/src/auth/pages/VerifyEmailPage.tsx +68 -0
- package/src/auth/register.ts +44 -0
- package/src/authRoles.ts +141 -0
- package/src/commands/MakeAdminResourceCommand.ts +181 -0
- package/src/config.ts +128 -0
- package/src/dashboardLayout.ts +101 -0
- package/src/databaseMedia.ts +148 -0
- package/src/databaseNotifications.ts +169 -0
- package/src/form/Field.ts +928 -0
- package/src/form/ResourceForm.ts +48 -0
- package/src/form/Section.ts +364 -0
- package/src/form/editors.ts +43 -0
- package/src/form/index.ts +59 -0
- package/src/history.ts +151 -0
- package/src/impersonation.ts +126 -0
- package/src/index.ts +380 -0
- package/src/infolist/Entry.ts +537 -0
- package/src/infolist/Section.ts +99 -0
- package/src/infolist/index.ts +38 -0
- package/src/media.ts +297 -0
- package/src/notifications.ts +65 -0
- package/src/pages/AdminPage.ts +100 -0
- package/src/pages/ConsolePage.tsx +324 -0
- package/src/pages/DashboardPage.tsx +264 -0
- package/src/pages/MediaPage.tsx +346 -0
- package/src/pages/NotificationsPage.tsx +155 -0
- package/src/pages/RecordViewPage.tsx +951 -0
- package/src/pages/ResourceFormPage.tsx +1856 -0
- package/src/pages/ResourceListPage.tsx +2552 -0
- package/src/pages/RolesPage.tsx +325 -0
- package/src/pages/SearchPage.tsx +169 -0
- package/src/plugin.ts +283 -0
- package/src/provider/AdminAbilityMiddleware.ts +25 -0
- package/src/provider/AdminGuardMiddleware.ts +29 -0
- package/src/provider/AdminProvider.ts +334 -0
- package/src/relations/RelationManager.ts +114 -0
- package/src/renderHooks.ts +86 -0
- package/src/roles.ts +175 -0
- package/src/savedViews.ts +79 -0
- package/src/support/ability.ts +73 -0
- package/src/support/authorize.ts +105 -0
- package/src/support/countCache.ts +37 -0
- package/src/support/hostPage.ts +30 -0
- package/src/table/Column.ts +353 -0
- package/src/table/Constraint.ts +238 -0
- package/src/table/Filter.ts +275 -0
- package/src/table/Group.ts +73 -0
- package/src/table/Tab.ts +77 -0
- package/src/testing.ts +121 -0
- package/src/theme.ts +70 -0
- package/src/ui/AdminLayout.tsx +355 -0
- package/src/ui/Breadcrumbs.tsx +84 -0
- package/src/ui/environmentIndicator.tsx +63 -0
- package/src/ui/icons.tsx +124 -0
- package/src/widgets/Widget.ts +251 -0
- package/src/widgets/render.tsx +154 -0
|
@@ -0,0 +1,644 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PanelInstance — one admin panel: its configuration, its resources, its custom
|
|
3
|
+
* pages, its dashboard widgets, and everything other packages contributed to it.
|
|
4
|
+
* The sidebar, the routes and the command palette are all derived from what is
|
|
5
|
+
* registered here.
|
|
6
|
+
*
|
|
7
|
+
* An application usually has exactly one, reached through the {@link Panel}
|
|
8
|
+
* facade. Apps that serve two audiences from one codebase — a staff back office
|
|
9
|
+
* at `/admin` and a customer console at `/app` — create additional instances
|
|
10
|
+
* with `Panel.make()`; each keeps its own registry, path prefix and guard.
|
|
11
|
+
*
|
|
12
|
+
* Packages never touch an instance directly. They push into {@link host}, which
|
|
13
|
+
* `AdminProvider` binds into the container as `admin.panel` — see `plugin.ts`
|
|
14
|
+
* for that side of the contract.
|
|
15
|
+
*/
|
|
16
|
+
import type { Resource } from "./Resource.ts";
|
|
17
|
+
import type { ClusterClass } from "./Cluster.ts";
|
|
18
|
+
import type { RenderHook, RenderHookName } from "./renderHooks.ts";
|
|
19
|
+
import type { SavedViewProvider } from "./savedViews.ts";
|
|
20
|
+
import type { MediaProvider } from "./media.ts";
|
|
21
|
+
import type { RoleProvider } from "./roles.ts";
|
|
22
|
+
import type { DashboardLayoutStore } from "./dashboardLayout.ts";
|
|
23
|
+
import { type AdminConfigShape, type AdminAuthConfig, DEFAULT_ADMIN_CONFIG } from "./config.ts";
|
|
24
|
+
import type { DashboardWidget } from "./widgets/Widget.ts";
|
|
25
|
+
import type { NotificationProvider } from "./notifications.ts";
|
|
26
|
+
import type { AdminPageClass } from "./pages/AdminPage.ts";
|
|
27
|
+
import type { BadgeTone } from "./table/Column.ts";
|
|
28
|
+
import { resolveAbility } from "./support/ability.ts";
|
|
29
|
+
import type {
|
|
30
|
+
AdminPanelHost,
|
|
31
|
+
AdminPlugin,
|
|
32
|
+
ConsoleContribution,
|
|
33
|
+
NavContribution,
|
|
34
|
+
PageContribution,
|
|
35
|
+
PanelPageClass,
|
|
36
|
+
PanelSearchProvider,
|
|
37
|
+
TopbarSlot,
|
|
38
|
+
UserMenuContribution,
|
|
39
|
+
WidgetContribution,
|
|
40
|
+
} from "./plugin.ts";
|
|
41
|
+
|
|
42
|
+
/** A Resource subclass (used by its static surface — never instantiated). */
|
|
43
|
+
export type ResourceClass = typeof Resource;
|
|
44
|
+
|
|
45
|
+
/** A custom page's path under the panel base, including any cluster segment. */
|
|
46
|
+
export function pagePath(page: Pick<PanelPage, "slug" | "cluster">): string {
|
|
47
|
+
return page.cluster ? `${page.cluster.slug}/${page.slug}` : page.slug;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export interface NavItem {
|
|
51
|
+
label: string;
|
|
52
|
+
slug: string;
|
|
53
|
+
icon: string;
|
|
54
|
+
href: string;
|
|
55
|
+
sort: number;
|
|
56
|
+
/** Parent item's label (for nested navigation). */
|
|
57
|
+
parent?: string | undefined;
|
|
58
|
+
/** Nested child items (resolved from `navigationParentItem`). */
|
|
59
|
+
children?: NavItem[];
|
|
60
|
+
/** Ability gating this entry, when it has one. */
|
|
61
|
+
ability?: string | undefined;
|
|
62
|
+
/** Link out of the panel rather than navigating within it. */
|
|
63
|
+
external?: boolean | undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface NavGroup {
|
|
67
|
+
group: string | null;
|
|
68
|
+
items: NavItem[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* A registered custom page, normalized from either door — an {@link AdminPage}
|
|
73
|
+
* subclass registered by the app, or a {@link PageContribution} pushed in by a
|
|
74
|
+
* package.
|
|
75
|
+
*/
|
|
76
|
+
export interface PanelPage {
|
|
77
|
+
slug: string;
|
|
78
|
+
page: PanelPageClass;
|
|
79
|
+
title: string;
|
|
80
|
+
ability: string | undefined;
|
|
81
|
+
/** The cluster this page belongs to, when it has one. */
|
|
82
|
+
cluster?: ClusterClass | undefined;
|
|
83
|
+
navigationLabel: string;
|
|
84
|
+
navigationIcon: string;
|
|
85
|
+
navigationGroup: string | undefined;
|
|
86
|
+
navigationSort: number;
|
|
87
|
+
showInNavigation: boolean;
|
|
88
|
+
routeParams: string[];
|
|
89
|
+
navigationBadge?: (() => Promise<string | number | null> | string | number | null) | undefined;
|
|
90
|
+
navigationBadgeColor?: BadgeTone | undefined;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export class PanelInstance {
|
|
94
|
+
/** Stable identifier, used for route naming and shell identity. */
|
|
95
|
+
readonly id: string;
|
|
96
|
+
|
|
97
|
+
private _resources: ResourceClass[] = [];
|
|
98
|
+
private _config: AdminConfigShape;
|
|
99
|
+
private _widgets: DashboardWidget[] = [];
|
|
100
|
+
private _notifications?: NotificationProvider | undefined;
|
|
101
|
+
private _pages: PanelPage[] = [];
|
|
102
|
+
private _consoles: ConsoleContribution[] = [];
|
|
103
|
+
private _contributedWidgets: WidgetContribution[] = [];
|
|
104
|
+
private _navItems: NavContribution[] = [];
|
|
105
|
+
private _searchProviders: PanelSearchProvider[] = [];
|
|
106
|
+
private _topbarSlots: TopbarSlot[] = [];
|
|
107
|
+
private _userMenuItems: UserMenuContribution[] = [];
|
|
108
|
+
private _renderHooks = new Map<RenderHookName, RenderHook[]>();
|
|
109
|
+
private _savedViews?: SavedViewProvider | undefined;
|
|
110
|
+
private _media?: MediaProvider | undefined;
|
|
111
|
+
private _mediaDisk?: string | undefined;
|
|
112
|
+
private _roles?: RoleProvider | undefined;
|
|
113
|
+
private _layout?: DashboardLayoutStore | undefined;
|
|
114
|
+
|
|
115
|
+
constructor(id: string, config: Partial<AdminConfigShape> = {}) {
|
|
116
|
+
this.id = id;
|
|
117
|
+
this._config = { ...DEFAULT_ADMIN_CONFIG, ...config };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Merge in panel configuration. */
|
|
121
|
+
configure(config: Partial<AdminConfigShape>): void {
|
|
122
|
+
this._config = { ...this._config, ...config };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
config(): AdminConfigShape {
|
|
126
|
+
return this._config;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** The panel's URL prefix, without a trailing slash. */
|
|
130
|
+
base(): string {
|
|
131
|
+
return this._config.path.replace(/\/$/, "");
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Register one or more resources (idempotent by slug). */
|
|
135
|
+
register(...resources: ResourceClass[]): void {
|
|
136
|
+
for (const r of resources) {
|
|
137
|
+
if (!this._resources.some((x) => x.getSlug() === r.getSlug())) {
|
|
138
|
+
this._resources.push(r);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
resources(): ResourceClass[] {
|
|
144
|
+
return this._resources;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Register dashboard widgets (stats overview and/or charts). */
|
|
148
|
+
widgets(...widgets: DashboardWidget[]): void {
|
|
149
|
+
this._widgets.push(...widgets);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
dashboardWidgets(): DashboardWidget[] {
|
|
153
|
+
return this._widgets;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Wire up the notification center (app supplies the data). */
|
|
157
|
+
notifications(provider: NotificationProvider): void {
|
|
158
|
+
this._notifications = provider;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
notificationProvider(): NotificationProvider | undefined {
|
|
162
|
+
return this._notifications;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/** Enable + configure the built-in auth pages (login / profile / reset / verify). */
|
|
166
|
+
auth(config: AdminAuthConfig): void {
|
|
167
|
+
this._config = { ...this._config, auth: { enabled: true, ...config } };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The resolved auth-pages config (or undefined when not enabled). */
|
|
171
|
+
authConfig(): AdminAuthConfig | undefined {
|
|
172
|
+
return this._config.auth?.enabled ? this._config.auth : undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Wire up saved list views (app supplies the storage). */
|
|
176
|
+
savedViews(provider: SavedViewProvider): void {
|
|
177
|
+
this._savedViews = provider;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** The saved-view provider, or undefined when the app configured none. */
|
|
181
|
+
savedViewProvider(): SavedViewProvider | undefined {
|
|
182
|
+
return this._savedViews;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Wire up the media library (app supplies the catalogue).
|
|
187
|
+
*
|
|
188
|
+
* `disk` names the storage disk files are written to and read back from. It
|
|
189
|
+
* matters which: the default disk is private and has no URL, so a library
|
|
190
|
+
* left on it stores uploads successfully and shows broken images for every
|
|
191
|
+
* one of them.
|
|
192
|
+
*/
|
|
193
|
+
media(provider: MediaProvider, options: { disk?: string } = {}): void {
|
|
194
|
+
this._media = provider;
|
|
195
|
+
this._mediaDisk = options.disk;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** The disk the media library reads and writes, or undefined for the default. */
|
|
199
|
+
mediaDisk(): string | undefined {
|
|
200
|
+
return this._mediaDisk;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** The media provider, or undefined when the app configured none. */
|
|
204
|
+
mediaProvider(): MediaProvider | undefined {
|
|
205
|
+
return this._media;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/** Wire up the roles and permissions page (app supplies the storage). */
|
|
209
|
+
roles(provider: RoleProvider): void {
|
|
210
|
+
this._roles = provider;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The role provider, or undefined when the app configured none. */
|
|
214
|
+
roleProvider(): RoleProvider | undefined {
|
|
215
|
+
return this._roles;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** Let each user arrange the dashboard (app supplies the storage). */
|
|
219
|
+
dashboardLayout(store: DashboardLayoutStore): void {
|
|
220
|
+
this._layout = store;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** The dashboard layout store, or undefined when the app configured none. */
|
|
224
|
+
dashboardLayoutStore(): DashboardLayoutStore | undefined {
|
|
225
|
+
return this._layout;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Resolve a resource by its URL slug. */
|
|
229
|
+
find(slug: string): ResourceClass | undefined {
|
|
230
|
+
return this._resources.find((r) => r.getSlug() === slug);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
// ── Custom pages ─────────────────────────────────────────────────────────────
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Register one or more {@link AdminPage} subclasses (idempotent by slug).
|
|
237
|
+
*
|
|
238
|
+
* This is the app-facing door. Packages contribute through {@link host}
|
|
239
|
+
* instead, so they need no dependency on this one.
|
|
240
|
+
*/
|
|
241
|
+
pages(...pages: AdminPageClass[]): void {
|
|
242
|
+
for (const p of pages) {
|
|
243
|
+
this._addPage({
|
|
244
|
+
slug: p.slug.replace(/^\/|\/$/g, ""),
|
|
245
|
+
page: p,
|
|
246
|
+
title: p.title,
|
|
247
|
+
ability: p.ability,
|
|
248
|
+
cluster: p.cluster,
|
|
249
|
+
navigationLabel: p.getNavigationLabel(),
|
|
250
|
+
navigationIcon: p.navigationIcon,
|
|
251
|
+
navigationGroup: p.navigationGroup,
|
|
252
|
+
navigationSort: p.navigationSort,
|
|
253
|
+
showInNavigation: p.showInNavigation,
|
|
254
|
+
routeParams: p.routeParams ?? [],
|
|
255
|
+
navigationBadge: p.navigationBadge ? () => p.navigationBadge!() : undefined,
|
|
256
|
+
navigationBadgeColor: p.navigationBadgeColor,
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
/** Every registered custom page, from both doors. */
|
|
262
|
+
registeredPages(): PanelPage[] {
|
|
263
|
+
return this._pages;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** Resolve a custom page by its slug. */
|
|
267
|
+
findPage(slug: string): PanelPage | undefined {
|
|
268
|
+
return this._pages.find((p) => p.slug === slug);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
private _addPage(page: PanelPage): void {
|
|
272
|
+
if (this._pages.some((p) => p.slug === page.slug)) return;
|
|
273
|
+
this._pages.push(page);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ── Contributions ────────────────────────────────────────────────────────────
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Install an app-authored plugin. The mirror of what a package does through the
|
|
280
|
+
* `admin.panel` binding, for code that can name the panel directly.
|
|
281
|
+
*/
|
|
282
|
+
async plugin(...plugins: AdminPlugin[]): Promise<void> {
|
|
283
|
+
for (const p of plugins) {
|
|
284
|
+
if (this.pluginEnabled(p.id)) await p.install(this.host());
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Whether a contributor is switched on. Contributors are on unless the app
|
|
290
|
+
* turns them off with `plugins: { monitor: false }` in `config/admin.ts`.
|
|
291
|
+
*/
|
|
292
|
+
pluginEnabled(id: string): boolean {
|
|
293
|
+
return this._config.plugins?.[id] !== false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/**
|
|
297
|
+
* The panel's write surface, bound into the container as `admin.panel`.
|
|
298
|
+
*
|
|
299
|
+
* Contributions are appended, never deduplicated by identity — a provider that
|
|
300
|
+
* boots twice in one process (some test harnesses do) would double up, so each
|
|
301
|
+
* registrar guards on the natural key where it has one.
|
|
302
|
+
*/
|
|
303
|
+
host(): AdminPanelHost {
|
|
304
|
+
return {
|
|
305
|
+
enabled: (id) => this.pluginEnabled(id),
|
|
306
|
+
page: (c: PageContribution) =>
|
|
307
|
+
this._addPage({
|
|
308
|
+
slug: c.slug.replace(/^\/|\/$/g, ""),
|
|
309
|
+
page: c.page,
|
|
310
|
+
title: c.title,
|
|
311
|
+
ability: c.ability,
|
|
312
|
+
cluster: c.cluster,
|
|
313
|
+
navigationLabel: c.navigationLabel ?? c.title,
|
|
314
|
+
navigationIcon: c.navigationIcon ?? "layout-grid",
|
|
315
|
+
navigationGroup: c.navigationGroup,
|
|
316
|
+
navigationSort: c.navigationSort ?? 0,
|
|
317
|
+
showInNavigation: c.showInNavigation ?? true,
|
|
318
|
+
routeParams: c.routeParams ?? [],
|
|
319
|
+
navigationBadge: c.navigationBadge,
|
|
320
|
+
navigationBadgeColor: c.navigationBadgeColor,
|
|
321
|
+
}),
|
|
322
|
+
console: (c: ConsoleContribution) => {
|
|
323
|
+
const slug = c.slug.replace(/^\/|\/$/g, "");
|
|
324
|
+
if (this._consoles.some((x) => x.slug === slug)) return;
|
|
325
|
+
this._consoles.push({ ...c, slug });
|
|
326
|
+
},
|
|
327
|
+
widget: (c) => {
|
|
328
|
+
this._contributedWidgets.push(c);
|
|
329
|
+
},
|
|
330
|
+
navItem: (c) => {
|
|
331
|
+
if (!this._navItems.some((n) => n.href === c.href)) this._navItems.push(c);
|
|
332
|
+
},
|
|
333
|
+
searchProvider: (p) => {
|
|
334
|
+
if (!this._searchProviders.some((s) => s.id === p.id)) this._searchProviders.push(p);
|
|
335
|
+
},
|
|
336
|
+
topbarSlot: (s) => {
|
|
337
|
+
if (!this._topbarSlots.some((t) => t.id === s.id)) this._topbarSlots.push(s);
|
|
338
|
+
},
|
|
339
|
+
renderHook: (name, hook) => {
|
|
340
|
+
this.renderHook(name as RenderHookName, hook as RenderHook);
|
|
341
|
+
},
|
|
342
|
+
userMenuItem: (i) => {
|
|
343
|
+
if (!this._userMenuItems.some((u) => u.href === i.href)) this._userMenuItems.push(i);
|
|
344
|
+
},
|
|
345
|
+
};
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* Render something at a named position in the panel's chrome. See
|
|
350
|
+
* {@link RenderHookName} for the positions.
|
|
351
|
+
*/
|
|
352
|
+
renderHook(name: RenderHookName, hook: RenderHook): void {
|
|
353
|
+
const list = this._renderHooks.get(name) ?? [];
|
|
354
|
+
list.push(hook);
|
|
355
|
+
this._renderHooks.set(name, list);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Hooks registered at `name`, in registration order. */
|
|
359
|
+
renderHooks(name: RenderHookName): RenderHook[] {
|
|
360
|
+
return this._renderHooks.get(name) ?? [];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Every registered console. */
|
|
364
|
+
consoles(): ConsoleContribution[] {
|
|
365
|
+
return this._consoles;
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
/** Resolve a console by its slug. */
|
|
369
|
+
findConsole(slug: string): ConsoleContribution | undefined {
|
|
370
|
+
return this._consoles.find((c) => c.slug === slug);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/** Contributed dashboard widgets, in sort order. Not ability-filtered — see {@link visibleWidgets}. */
|
|
374
|
+
contributedWidgets(): WidgetContribution[] {
|
|
375
|
+
return [...this._contributedWidgets].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/** Contributed dashboard widgets the current user may see. */
|
|
379
|
+
async visibleWidgets(): Promise<DashboardWidget[]> {
|
|
380
|
+
const allowed = await this._filterByAbility(this.contributedWidgets(), (w) => w.ability);
|
|
381
|
+
return allowed.map((w) => w.widget);
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/** Contributed global-search providers the current user may query. */
|
|
385
|
+
visibleSearchProviders(): Promise<PanelSearchProvider[]> {
|
|
386
|
+
return this._filterByAbility(this._searchProviders, (p) => p.ability);
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
/** Contributed top-bar slots the current user may see, in sort order. */
|
|
390
|
+
visibleTopbarSlots(): Promise<TopbarSlot[]> {
|
|
391
|
+
const sorted = [...this._topbarSlots].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
|
392
|
+
return this._filterByAbility(sorted, (s) => s.ability);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Contributed user-menu entries the current user may see, in sort order. */
|
|
396
|
+
visibleUserMenuItems(): Promise<UserMenuContribution[]> {
|
|
397
|
+
const sorted = [...this._userMenuItems].sort((a, b) => (a.sort ?? 0) - (b.sort ?? 0));
|
|
398
|
+
return this._filterByAbility(sorted, (i) => i.ability);
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
// ── Authorization ────────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Whether the current user holds `ability`, resolved through the app's
|
|
405
|
+
* `authorize` hook, then the `gate` binding, then a fail-closed default.
|
|
406
|
+
*
|
|
407
|
+
* Resources are *not* filtered through here — they authorize through their own
|
|
408
|
+
* `Resource.can("viewAny")`, which carries record context this cannot.
|
|
409
|
+
*/
|
|
410
|
+
can(ability: string | undefined): Promise<boolean> {
|
|
411
|
+
return resolveAbility(ability, this._config.authorize);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/** Keep the entries whose ability the current user holds, preserving order. */
|
|
415
|
+
private async _filterByAbility<T>(
|
|
416
|
+
items: T[],
|
|
417
|
+
abilityOf: (item: T) => string | undefined,
|
|
418
|
+
): Promise<T[]> {
|
|
419
|
+
const verdicts = await Promise.all(items.map((i) => this.can(abilityOf(i))));
|
|
420
|
+
return items.filter((_, i) => verdicts[i] === true);
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Resolve every resource's sidebar navigation badge, keyed by slug. Failures
|
|
425
|
+
* are swallowed so a broken badge query never takes down the whole sidebar.
|
|
426
|
+
*/
|
|
427
|
+
async navigationBadges(): Promise<Record<string, { text: string; color: string }>> {
|
|
428
|
+
const out: Record<string, { text: string; color: string }> = {};
|
|
429
|
+
const record = async (
|
|
430
|
+
slug: string,
|
|
431
|
+
resolve: () => Promise<string | number | null> | string | number | null,
|
|
432
|
+
color: BadgeTone,
|
|
433
|
+
): Promise<void> => {
|
|
434
|
+
try {
|
|
435
|
+
const b = await resolve();
|
|
436
|
+
if (b !== null && b !== undefined && b !== "") out[slug] = { text: String(b), color };
|
|
437
|
+
} catch {
|
|
438
|
+
/* ignore a failing badge query */
|
|
439
|
+
}
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
await Promise.all([
|
|
443
|
+
...this._resources.map((r) =>
|
|
444
|
+
record(r.getSlug(), () => r.navigationBadge(), r.navigationBadgeColor),
|
|
445
|
+
),
|
|
446
|
+
...this._pages
|
|
447
|
+
.filter((p) => p.navigationBadge)
|
|
448
|
+
.map((p) => record(p.slug, p.navigationBadge!, p.navigationBadgeColor ?? "primary")),
|
|
449
|
+
...this._consoles
|
|
450
|
+
.filter((c) => c.navigationBadge)
|
|
451
|
+
.map((c) => record(c.slug, c.navigationBadge!, c.navigationBadgeColor ?? "primary")),
|
|
452
|
+
]);
|
|
453
|
+
return out;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
/** Reset the registry (tests). */
|
|
457
|
+
reset(): void {
|
|
458
|
+
this._resources = [];
|
|
459
|
+
this._config = { ...DEFAULT_ADMIN_CONFIG };
|
|
460
|
+
this._widgets = [];
|
|
461
|
+
this._notifications = undefined;
|
|
462
|
+
this._pages = [];
|
|
463
|
+
this._consoles = [];
|
|
464
|
+
this._contributedWidgets = [];
|
|
465
|
+
this._navItems = [];
|
|
466
|
+
this._searchProviders = [];
|
|
467
|
+
this._topbarSlots = [];
|
|
468
|
+
this._userMenuItems = [];
|
|
469
|
+
this._renderHooks.clear();
|
|
470
|
+
this._savedViews = undefined;
|
|
471
|
+
this._media = undefined;
|
|
472
|
+
this._mediaDisk = undefined;
|
|
473
|
+
this._roles = undefined;
|
|
474
|
+
this._layout = undefined;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* Build the sidebar navigation, grouped and sorted — resources, custom pages
|
|
479
|
+
* and contributed links together.
|
|
480
|
+
*
|
|
481
|
+
* Entries are *not* ability-filtered here; this is the full map, used for route
|
|
482
|
+
* mounting and tests. What a given user may see is {@link visibleNavigation}.
|
|
483
|
+
*/
|
|
484
|
+
navigation(): NavGroup[] {
|
|
485
|
+
const base = this.base();
|
|
486
|
+
const groups = new Map<string | null, NavItem[]>();
|
|
487
|
+
const push = (group: string | null, item: NavItem): void => {
|
|
488
|
+
const list = groups.get(group) ?? [];
|
|
489
|
+
list.push(item);
|
|
490
|
+
groups.set(group, list);
|
|
491
|
+
};
|
|
492
|
+
|
|
493
|
+
// Clustered members collect under one entry rather than appearing loose in
|
|
494
|
+
// the sidebar; the cluster is keyed by identity so two with the same title
|
|
495
|
+
// stay distinct.
|
|
496
|
+
const clustered = new Map<ClusterClass, NavItem[]>();
|
|
497
|
+
|
|
498
|
+
for (const r of this._resources) {
|
|
499
|
+
// A nested resource is reached through its parent's records, not from the
|
|
500
|
+
// sidebar — there is no single URL for "all comments of every post".
|
|
501
|
+
if (r.parent) continue;
|
|
502
|
+
const item: NavItem = {
|
|
503
|
+
label: r.getPluralLabel(),
|
|
504
|
+
slug: r.getSlug(),
|
|
505
|
+
icon: r.navigationIcon,
|
|
506
|
+
href: r.indexUrl(base),
|
|
507
|
+
sort: r.navigationSort,
|
|
508
|
+
parent: r.navigationParentItem,
|
|
509
|
+
};
|
|
510
|
+
if (r.cluster) {
|
|
511
|
+
const members = clustered.get(r.cluster) ?? [];
|
|
512
|
+
members.push(item);
|
|
513
|
+
clustered.set(r.cluster, members);
|
|
514
|
+
} else {
|
|
515
|
+
push(r.navigationGroup ?? null, item);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
for (const p of this._pages) {
|
|
520
|
+
if (!p.showInNavigation) continue;
|
|
521
|
+
const item: NavItem = {
|
|
522
|
+
label: p.navigationLabel,
|
|
523
|
+
slug: p.slug,
|
|
524
|
+
icon: p.navigationIcon,
|
|
525
|
+
href: `${base}/${pagePath(p)}`,
|
|
526
|
+
sort: p.navigationSort,
|
|
527
|
+
ability: p.ability,
|
|
528
|
+
};
|
|
529
|
+
if (p.cluster) {
|
|
530
|
+
const members = clustered.get(p.cluster) ?? [];
|
|
531
|
+
members.push(item);
|
|
532
|
+
clustered.set(p.cluster, members);
|
|
533
|
+
} else {
|
|
534
|
+
push(p.navigationGroup ?? null, item);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
for (const c of this._consoles) {
|
|
539
|
+
if (c.showInNavigation === false) continue;
|
|
540
|
+
push(c.navigationGroup ?? null, {
|
|
541
|
+
label: c.navigationLabel ?? c.title,
|
|
542
|
+
slug: c.slug,
|
|
543
|
+
icon: c.navigationIcon ?? "layout-grid",
|
|
544
|
+
href: `${base}/${c.slug}`,
|
|
545
|
+
sort: c.navigationSort ?? 0,
|
|
546
|
+
ability: c.ability,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
for (const n of this._navItems) {
|
|
551
|
+
push(n.group ?? null, {
|
|
552
|
+
label: n.label,
|
|
553
|
+
slug: n.href,
|
|
554
|
+
icon: n.icon ?? "layout-grid",
|
|
555
|
+
href: n.href,
|
|
556
|
+
sort: n.sort ?? 0,
|
|
557
|
+
ability: n.ability,
|
|
558
|
+
external: n.external,
|
|
559
|
+
});
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Each cluster becomes one entry whose children are its members. The entry
|
|
563
|
+
// links to its first member, since a cluster is a grouping rather than a
|
|
564
|
+
// destination of its own.
|
|
565
|
+
for (const [cluster, members] of clustered) {
|
|
566
|
+
members.sort((a, b) => a.sort - b.sort || a.label.localeCompare(b.label));
|
|
567
|
+
push(cluster.navigationGroup ?? null, {
|
|
568
|
+
label: cluster.getNavigationLabel(),
|
|
569
|
+
slug: cluster.slug,
|
|
570
|
+
icon: cluster.navigationIcon,
|
|
571
|
+
href: members[0]?.href ?? cluster.url(base),
|
|
572
|
+
sort: cluster.navigationSort,
|
|
573
|
+
ability: cluster.ability,
|
|
574
|
+
children: members,
|
|
575
|
+
});
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
const sortItems = (a: NavItem, b: NavItem): number =>
|
|
579
|
+
a.sort - b.sort || a.label.localeCompare(b.label);
|
|
580
|
+
|
|
581
|
+
const result: NavGroup[] = [];
|
|
582
|
+
for (const [group, items] of groups) {
|
|
583
|
+
// Nest items declaring a `parent` under the matching label; others stay top-level.
|
|
584
|
+
const byLabel = new Map(items.map((i) => [i.label, i]));
|
|
585
|
+
const top: NavItem[] = [];
|
|
586
|
+
for (const i of items) {
|
|
587
|
+
const parent = i.parent ? byLabel.get(i.parent) : undefined;
|
|
588
|
+
if (parent && parent !== i) (parent.children ??= []).push(i);
|
|
589
|
+
else top.push(i);
|
|
590
|
+
}
|
|
591
|
+
top.sort(sortItems);
|
|
592
|
+
for (const t of top) t.children?.sort(sortItems);
|
|
593
|
+
result.push({ group, items: top });
|
|
594
|
+
}
|
|
595
|
+
// Ungrouped first, then groups alphabetically.
|
|
596
|
+
result.sort((a, b) => {
|
|
597
|
+
if (a.group === null) return -1;
|
|
598
|
+
if (b.group === null) return 1;
|
|
599
|
+
return a.group.localeCompare(b.group);
|
|
600
|
+
});
|
|
601
|
+
return result;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
/**
|
|
605
|
+
* The navigation as the current user may see it: entries whose ability they
|
|
606
|
+
* lack are dropped, along with any group left empty.
|
|
607
|
+
*
|
|
608
|
+
* Resource entries are filtered by `Resource.can("viewAny")`; page and
|
|
609
|
+
* contributed entries by their declared ability. A parent whose ability is
|
|
610
|
+
* denied takes its children with it.
|
|
611
|
+
*/
|
|
612
|
+
async visibleNavigation(): Promise<NavGroup[]> {
|
|
613
|
+
const resourceBySlug = new Map(this._resources.map((r) => [r.getSlug(), r]));
|
|
614
|
+
|
|
615
|
+
const allowed = async (item: NavItem): Promise<boolean> => {
|
|
616
|
+
const resource = resourceBySlug.get(item.slug);
|
|
617
|
+
if (resource) {
|
|
618
|
+
try {
|
|
619
|
+
return resource.can("viewAny");
|
|
620
|
+
} catch {
|
|
621
|
+
return false;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
return this.can(item.ability);
|
|
625
|
+
};
|
|
626
|
+
|
|
627
|
+
const groups: NavGroup[] = [];
|
|
628
|
+
for (const group of this.navigation()) {
|
|
629
|
+
const items: NavItem[] = [];
|
|
630
|
+
for (const item of group.items) {
|
|
631
|
+
if (!(await allowed(item))) continue;
|
|
632
|
+
if (item.children?.length) {
|
|
633
|
+
const children: NavItem[] = [];
|
|
634
|
+
for (const child of item.children) if (await allowed(child)) children.push(child);
|
|
635
|
+
items.push({ ...item, children });
|
|
636
|
+
} else {
|
|
637
|
+
items.push(item);
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
if (items.length > 0) groups.push({ group: group.group, items });
|
|
641
|
+
}
|
|
642
|
+
return groups;
|
|
643
|
+
}
|
|
644
|
+
}
|