@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
package/src/plugin.ts
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The panel's contribution surface — how packages other than the app itself add
|
|
3
|
+
* functionality to the admin.
|
|
4
|
+
*
|
|
5
|
+
* The panel is a *host*: it publishes the write surface below, binds it into the
|
|
6
|
+
* container as `admin.panel`, and never names a single contributor. A package
|
|
7
|
+
* contributes by resolving that binding at boot and pushing into it:
|
|
8
|
+
*
|
|
9
|
+
* // packages/queue/src/admin.ts
|
|
10
|
+
* interface AdminHost { // declared locally — no admin dependency
|
|
11
|
+
* enabled(id: string): boolean;
|
|
12
|
+
* page(c: { slug: string; page: unknown; title: string; ability: string }): void;
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* export function installQueueAdmin(app: Application): void {
|
|
16
|
+
* const panel = app.container.tryMake("admin.panel" as never) as AdminHost | undefined;
|
|
17
|
+
* if (!panel?.enabled("queue")) return;
|
|
18
|
+
* panel.page({ slug: "jobs", page: JobsPage, title: "Jobs", ability: "queue.view" });
|
|
19
|
+
* }
|
|
20
|
+
*
|
|
21
|
+
* Called from the contributing provider's `onBooted`, this is zero-config for the
|
|
22
|
+
* app — installing both providers is enough — and costs nothing when the admin
|
|
23
|
+
* isn't installed, because the binding simply isn't there. It mirrors how the
|
|
24
|
+
* observability sinks are wired, so there is one extension idiom to learn.
|
|
25
|
+
*
|
|
26
|
+
* Apps use the more direct door: an {@link AdminPlugin} passed to `Panel.plugin()`,
|
|
27
|
+
* or an {@link AdminPage} subclass passed to `Panel.pages()`.
|
|
28
|
+
*/
|
|
29
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
30
|
+
import type { DashboardWidget } from "./widgets/Widget.ts";
|
|
31
|
+
import type { BadgeTone } from "./table/Column.ts";
|
|
32
|
+
import type { ClusterClass } from "./Cluster.ts";
|
|
33
|
+
import type { RenderHookContext } from "./renderHooks.ts";
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* A page component class, structurally — a Flow `Component` subclass.
|
|
37
|
+
*
|
|
38
|
+
* Typed as a bare zero-argument constructor on purpose. It's what Flow's router
|
|
39
|
+
* actually does with a page class, and it lets a contributing package hand one
|
|
40
|
+
* over without importing anything from `@zerotal/admin`. The panel gives the page
|
|
41
|
+
* its own layout when it mounts the route, so a contributed page renders only its
|
|
42
|
+
* content and inherits the panel's chrome.
|
|
43
|
+
*/
|
|
44
|
+
export type PanelPageClass = new () => object;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A page contributed by a package.
|
|
48
|
+
*
|
|
49
|
+
* `ability` is **required** here, unlike on an app-authored {@link AdminPage}.
|
|
50
|
+
* Contributions register themselves without the app asking, so the ability is the
|
|
51
|
+
* only thing standing between a package's page and every user who can reach the
|
|
52
|
+
* panel — a contributed page with no ability would be a package deciding who sees
|
|
53
|
+
* production internals, which is not the package's decision to make.
|
|
54
|
+
*/
|
|
55
|
+
export interface PageContribution {
|
|
56
|
+
/** Path under the panel root, without leading slash — `"jobs"`, `"monitor/requests"`. */
|
|
57
|
+
slug: string;
|
|
58
|
+
/** The page component class. Mounted with the panel's layout and guard. */
|
|
59
|
+
page: PanelPageClass;
|
|
60
|
+
/** Page title, and the navigation label unless `navigationLabel` overrides it. */
|
|
61
|
+
title: string;
|
|
62
|
+
/** Ability required to see the nav entry and open the route. */
|
|
63
|
+
ability: string;
|
|
64
|
+
/** Sidebar label, when it should differ from the title. */
|
|
65
|
+
navigationLabel?: string;
|
|
66
|
+
/** Icon name from the panel's icon set. */
|
|
67
|
+
navigationIcon?: string;
|
|
68
|
+
/** Sidebar group heading. Ungrouped entries sort above every group. */
|
|
69
|
+
navigationGroup?: string;
|
|
70
|
+
/** A cluster to file this page under, sharing its URL segment and nav entry. */
|
|
71
|
+
cluster?: ClusterClass;
|
|
72
|
+
/** Sort weight within the group. Ties break alphabetically. */
|
|
73
|
+
navigationSort?: number;
|
|
74
|
+
/** Mount the route but keep it out of the sidebar (detail pages, drill-ins). */
|
|
75
|
+
showInNavigation?: boolean;
|
|
76
|
+
/**
|
|
77
|
+
* Extra route patterns mounted onto the same page, relative to `slug` — e.g.
|
|
78
|
+
* `[":section"]` so one page serves `/jobs` and `/jobs/failed`.
|
|
79
|
+
*/
|
|
80
|
+
routeParams?: string[];
|
|
81
|
+
/** A count pill beside the sidebar entry. Failures are swallowed. */
|
|
82
|
+
navigationBadge?: () => Promise<string | number | null> | string | number | null;
|
|
83
|
+
/** Tone of the navigation badge. */
|
|
84
|
+
navigationBadgeColor?: BadgeTone;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ── Consoles ─────────────────────────────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* A row in a console table — whatever shape the contributing package hands over.
|
|
91
|
+
*/
|
|
92
|
+
export type ConsoleRow = Record<string, unknown>;
|
|
93
|
+
|
|
94
|
+
/** One column of a console table. */
|
|
95
|
+
export interface ConsoleColumn {
|
|
96
|
+
/** Property read from the row. */
|
|
97
|
+
key: string;
|
|
98
|
+
label: string;
|
|
99
|
+
align?: "start" | "center" | "end";
|
|
100
|
+
/** Render in a monospace face — ids, class names, error text. */
|
|
101
|
+
mono?: boolean;
|
|
102
|
+
/** Turn the raw value into display text. Defaults to `String(value)`. */
|
|
103
|
+
format?: (value: unknown, row: ConsoleRow) => string;
|
|
104
|
+
/** Render the cell as a badge in this tone, or `null` for plain text. */
|
|
105
|
+
badge?: (value: unknown, row: ConsoleRow) => BadgeTone | null;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** An action offered on every row of a console table. */
|
|
109
|
+
export interface ConsoleAction {
|
|
110
|
+
key: string;
|
|
111
|
+
label: string;
|
|
112
|
+
icon?: string;
|
|
113
|
+
danger?: boolean;
|
|
114
|
+
/** Ask for confirmation with this message before running. */
|
|
115
|
+
confirm?: string;
|
|
116
|
+
/** Run against one row. Return a message to flash on success. */
|
|
117
|
+
run: (row: ConsoleRow) => Promise<string | void> | string | void;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** An action in a console tab's header — operating on the tab, not on a row. */
|
|
121
|
+
export interface ConsoleHeaderAction {
|
|
122
|
+
key: string;
|
|
123
|
+
label: string;
|
|
124
|
+
icon?: string;
|
|
125
|
+
danger?: boolean;
|
|
126
|
+
confirm?: string;
|
|
127
|
+
run: () => Promise<string | void> | string | void;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** One tab of a console — a table, plus what can be done to it. */
|
|
131
|
+
export interface ConsoleTab {
|
|
132
|
+
key: string;
|
|
133
|
+
label: string;
|
|
134
|
+
/** Explanatory line under the heading. */
|
|
135
|
+
description?: string;
|
|
136
|
+
columns: ConsoleColumn[];
|
|
137
|
+
rows: () => Promise<ConsoleRow[]> | ConsoleRow[];
|
|
138
|
+
/** Property identifying a row, used for morph keys. Defaults to `"id"`. */
|
|
139
|
+
rowKey?: string;
|
|
140
|
+
rowActions?: ConsoleAction[];
|
|
141
|
+
headerActions?: ConsoleHeaderAction[];
|
|
142
|
+
/** Shown in place of an empty table. */
|
|
143
|
+
empty?: string;
|
|
144
|
+
/** A count pill on the tab itself. */
|
|
145
|
+
badge?: () => Promise<number | null> | number | null;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* A read-and-act page described as data rather than built as a component.
|
|
150
|
+
*
|
|
151
|
+
* Most packages want the same page: some tables, a few buttons, no bespoke
|
|
152
|
+
* layout. Describing that instead of rendering it means the contributing package
|
|
153
|
+
* needs no JSX, no `@zerotal/flow` dependency and no build configuration —
|
|
154
|
+
* the panel owns the markup, so every console also looks like the rest of the
|
|
155
|
+
* admin without trying to.
|
|
156
|
+
*
|
|
157
|
+
* Reach for {@link PageContribution} instead when a page genuinely needs its own
|
|
158
|
+
* component: charts, a custom layout, its own reactive state.
|
|
159
|
+
*/
|
|
160
|
+
export interface ConsoleContribution {
|
|
161
|
+
/** Path under the panel root, without leading slash. */
|
|
162
|
+
slug: string;
|
|
163
|
+
title: string;
|
|
164
|
+
/** Ability required to see the nav entry and open the route. */
|
|
165
|
+
ability: string;
|
|
166
|
+
tabs: ConsoleTab[];
|
|
167
|
+
navigationLabel?: string;
|
|
168
|
+
navigationIcon?: string;
|
|
169
|
+
navigationGroup?: string;
|
|
170
|
+
navigationSort?: number;
|
|
171
|
+
showInNavigation?: boolean;
|
|
172
|
+
/** A count pill beside the sidebar entry. Failures are swallowed. */
|
|
173
|
+
navigationBadge?: () => Promise<string | number | null> | string | number | null;
|
|
174
|
+
/** Tone of the navigation badge. */
|
|
175
|
+
navigationBadgeColor?: BadgeTone;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** A dashboard widget contributed by a package. */
|
|
179
|
+
export interface WidgetContribution {
|
|
180
|
+
widget: DashboardWidget;
|
|
181
|
+
/** Ability required to see this widget on the dashboard. */
|
|
182
|
+
ability: string;
|
|
183
|
+
/** Sort weight among contributed widgets. Lower renders first. */
|
|
184
|
+
sort?: number;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** A sidebar link that points somewhere the panel doesn't mount itself. */
|
|
188
|
+
export interface NavContribution {
|
|
189
|
+
label: string;
|
|
190
|
+
href: string;
|
|
191
|
+
icon?: string;
|
|
192
|
+
group?: string;
|
|
193
|
+
sort?: number;
|
|
194
|
+
/** Ability required to see the link. */
|
|
195
|
+
ability: string;
|
|
196
|
+
/** Open in a new tab and skip client-side navigation. */
|
|
197
|
+
external?: boolean;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** One result row from a contributed search provider. */
|
|
201
|
+
export interface SearchHit {
|
|
202
|
+
label: string;
|
|
203
|
+
href: string;
|
|
204
|
+
description?: string;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* A source of global-search results beyond the registered resources — log lines,
|
|
209
|
+
* jobs, audit entries, anything with a stable URL.
|
|
210
|
+
*/
|
|
211
|
+
export interface PanelSearchProvider {
|
|
212
|
+
/** Stable id, used for the opt-out check and as the result group key. */
|
|
213
|
+
id: string;
|
|
214
|
+
/** Group heading shown above this provider's hits. */
|
|
215
|
+
label: string;
|
|
216
|
+
icon?: string;
|
|
217
|
+
/** Ability required to search this source. */
|
|
218
|
+
ability: string;
|
|
219
|
+
search(term: string): Promise<SearchHit[]> | SearchHit[];
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** A status pill, indicator, or control rendered in the panel's top bar. */
|
|
223
|
+
export interface TopbarSlot {
|
|
224
|
+
id: string;
|
|
225
|
+
/** Ability required to render the slot. */
|
|
226
|
+
ability: string;
|
|
227
|
+
/** Lower renders further left. */
|
|
228
|
+
sort?: number;
|
|
229
|
+
render(): HtmlNode | Promise<HtmlNode>;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** An extra entry in the top-bar user menu. */
|
|
233
|
+
export interface UserMenuContribution {
|
|
234
|
+
label: string;
|
|
235
|
+
href: string;
|
|
236
|
+
icon?: string;
|
|
237
|
+
sort?: number;
|
|
238
|
+
/** Ability required to see the entry. */
|
|
239
|
+
ability: string;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* The panel's write surface, bound into the container as `admin.panel`.
|
|
244
|
+
*
|
|
245
|
+
* Contributors should declare their own minimal copy of the members they use
|
|
246
|
+
* rather than importing this type, so they depend on the admin package at build
|
|
247
|
+
* time not at all.
|
|
248
|
+
*/
|
|
249
|
+
export interface AdminPanelHost {
|
|
250
|
+
/**
|
|
251
|
+
* Whether the app has left this contributor switched on. Check it first and
|
|
252
|
+
* return early — `plugins: { monitor: false }` in `config/admin.ts` turns a
|
|
253
|
+
* contributor off without uninstalling its provider.
|
|
254
|
+
*/
|
|
255
|
+
enabled(id: string): boolean;
|
|
256
|
+
page(contribution: PageContribution): void;
|
|
257
|
+
/**
|
|
258
|
+
* Add a table-and-actions page described as data. The cheaper door — no
|
|
259
|
+
* component, no JSX, no dependency on Flow.
|
|
260
|
+
*/
|
|
261
|
+
console(contribution: ConsoleContribution): void;
|
|
262
|
+
widget(contribution: WidgetContribution): void;
|
|
263
|
+
navItem(contribution: NavContribution): void;
|
|
264
|
+
searchProvider(provider: PanelSearchProvider): void;
|
|
265
|
+
topbarSlot(slot: TopbarSlot): void;
|
|
266
|
+
userMenuItem(item: UserMenuContribution): void;
|
|
267
|
+
/**
|
|
268
|
+
* Render into a named position in the panel's chrome — a banner above every
|
|
269
|
+
* table, a notice under every form. See `RenderHookName` for the positions.
|
|
270
|
+
*/
|
|
271
|
+
renderHook(name: string, hook: (context: RenderHookContext) => HtmlNode | string | null): void;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* The app-side door into the same registry. Where a package pushes itself in via
|
|
276
|
+
* the container binding, an app names what it wants:
|
|
277
|
+
*
|
|
278
|
+
* Panel.plugin({ id: "billing", install: (panel) => panel.page({ … }) });
|
|
279
|
+
*/
|
|
280
|
+
export interface AdminPlugin {
|
|
281
|
+
id: string;
|
|
282
|
+
install(panel: AdminPanelHost): void | Promise<void>;
|
|
283
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { BaseMiddleware } from "@zerotal/core";
|
|
2
|
+
import type { NextFn, HttpContext } from "@zerotal/core";
|
|
3
|
+
import { Panel } from "../Panel.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Enforces a page's declared ability at the route.
|
|
7
|
+
*
|
|
8
|
+
* The sidebar already hides destinations the user may not reach, but hiding a
|
|
9
|
+
* link is presentation, not access control — the URL is still typeable. This
|
|
10
|
+
* middleware runs the *same* {@link Panel.can} check the sidebar used, so the two
|
|
11
|
+
* can't drift: what you cannot see, you cannot open.
|
|
12
|
+
*
|
|
13
|
+
* Mounted per page by {@link AdminProvider}, on top of the panel-wide guard.
|
|
14
|
+
*/
|
|
15
|
+
export class AdminAbilityMiddleware extends BaseMiddleware<{ ability?: string | undefined }> {
|
|
16
|
+
protected options: { ability?: string | undefined } = {};
|
|
17
|
+
|
|
18
|
+
async handle(_http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
19
|
+
if (await Panel.can(this.options.ability)) return next();
|
|
20
|
+
return new Response("Not authorized.\n", {
|
|
21
|
+
status: 403,
|
|
22
|
+
headers: { "Content-Type": "text/plain; charset=utf-8" },
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { BaseMiddleware, isDevSurfaceAllowed } from "@zerotal/core";
|
|
2
|
+
import type { NextFn, HttpContext } from "@zerotal/core";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fail-closed default guard for the admin panel.
|
|
6
|
+
*
|
|
7
|
+
* The panel exposes full CRUD, so shipping it with no guard is a footgun. When
|
|
8
|
+
* `config/admin.ts` declares no `middleware`, the provider installs this guard
|
|
9
|
+
* so the panel is **denied by default** in any production-like environment
|
|
10
|
+
* (unset/unknown `APP_ENV`, `staging`, `production`). Local exploration
|
|
11
|
+
* (`APP_ENV=development|local|test`) still passes through.
|
|
12
|
+
*
|
|
13
|
+
* To run the panel in production, set `middleware` in `config/admin.ts` to a
|
|
14
|
+
* real auth/authorization stack (e.g. `[AuthMiddleware.with({ ... })]`). To
|
|
15
|
+
* *deliberately* expose it without auth, set an explicit pass-through
|
|
16
|
+
* middleware — an intentional, visible opt-out rather than a silent default.
|
|
17
|
+
*/
|
|
18
|
+
export class AdminGuardMiddleware extends BaseMiddleware {
|
|
19
|
+
protected options = {};
|
|
20
|
+
|
|
21
|
+
async handle(_http: HttpContext, next: NextFn): Promise<Response | void> {
|
|
22
|
+
if (isDevSurfaceAllowed(Bun.env["APP_ENV"] ?? "")) return next();
|
|
23
|
+
return new Response(
|
|
24
|
+
"Admin panel is not accessible: no authentication is configured.\n" +
|
|
25
|
+
"Set `middleware` in config/admin.ts to a real auth guard before exposing it.\n",
|
|
26
|
+
{ status: 403, headers: { "Content-Type": "text/plain; charset=utf-8" } },
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AdminProvider — wires the admin panel into the application.
|
|
3
|
+
*
|
|
4
|
+
* Just register `AdminProvider`; it depends on `FlowProvider` (which installs the
|
|
5
|
+
* `Router.flow()` macro and the WebSocket runtime the pages rely on), so Flow is
|
|
6
|
+
* pulled in automatically and guaranteed to boot first — you do not have to list it:
|
|
7
|
+
*
|
|
8
|
+
* import { AdminProvider } from "@zerotal/admin";
|
|
9
|
+
* export default [AdminProvider]; // FlowProvider comes along via dependsOn
|
|
10
|
+
*
|
|
11
|
+
* On boot it loads `app/admin.ts` (where you call `Panel.register(...)`), then
|
|
12
|
+
* mounts a Dashboard page plus one List page per registered resource under the
|
|
13
|
+
* configured path (default `/admin`).
|
|
14
|
+
*
|
|
15
|
+
* It also publishes the panel as a *host*: `admin.panel` is bound during
|
|
16
|
+
* `onRegister`, so any provider can contribute pages, widgets, nav entries and
|
|
17
|
+
* search providers from its own `onBooting` without depending on this package.
|
|
18
|
+
* Routes are mounted in `onBooted`, once every contribution is in.
|
|
19
|
+
*/
|
|
20
|
+
import { ServiceProvider, Router, FrameworkEvents } from "@zerotal/core";
|
|
21
|
+
import type { ModelChanged } from "@zerotal/orm";
|
|
22
|
+
import type { AppEnvironment, MiddlewareClass, HttpContext } from "@zerotal/core";
|
|
23
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
24
|
+
import { FlowProvider } from "@zerotal/flow";
|
|
25
|
+
import { Panel } from "../Panel.ts";
|
|
26
|
+
import type { PanelInstance } from "../PanelInstance.ts";
|
|
27
|
+
import { pagePath } from "../PanelInstance.ts";
|
|
28
|
+
import { AdminGuardMiddleware } from "./AdminGuardMiddleware.ts";
|
|
29
|
+
import { AdminAbilityMiddleware } from "./AdminAbilityMiddleware.ts";
|
|
30
|
+
import type { AdminPanelHost } from "../plugin.ts";
|
|
31
|
+
import type { AdminConfigShape } from "../config.ts";
|
|
32
|
+
import { makeDashboardPage } from "../pages/DashboardPage.tsx";
|
|
33
|
+
import { makeSearchPage } from "../pages/SearchPage.tsx";
|
|
34
|
+
import { makeNotificationsPage } from "../pages/NotificationsPage.tsx";
|
|
35
|
+
import { makeMediaPage } from "../pages/MediaPage.tsx";
|
|
36
|
+
import { makeRolesPage } from "../pages/RolesPage.tsx";
|
|
37
|
+
import { makeResourceListPage } from "../pages/ResourceListPage.tsx";
|
|
38
|
+
import { makeRecordViewPage } from "../pages/RecordViewPage.tsx";
|
|
39
|
+
import { ResourceFormPage, registerResourceForm } from "../pages/ResourceFormPage.tsx";
|
|
40
|
+
import { makeConsolePage } from "../pages/ConsolePage.tsx";
|
|
41
|
+
import { makeResourceForm } from "../form/index.ts";
|
|
42
|
+
import { forgetTabCounts } from "../support/countCache.ts";
|
|
43
|
+
import { hostedPage } from "../support/hostPage.ts";
|
|
44
|
+
import { frameworkLog } from "@zerotal/core/logger";
|
|
45
|
+
|
|
46
|
+
declare module "@zerotal/core" {
|
|
47
|
+
interface ContainerBindings {
|
|
48
|
+
/** The panel's contribution surface — see `plugin.ts`. */
|
|
49
|
+
"admin.panel": AdminPanelHost;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class AdminProvider extends ServiceProvider {
|
|
54
|
+
static override provides = ["admin.panel"] as const;
|
|
55
|
+
// The panel mounts HTTP routes via Flow's `Router.flow()` macro, so Flow must
|
|
56
|
+
// boot first — declared here so registering AdminProvider pulls it in automatically.
|
|
57
|
+
static override dependsOn = [FlowProvider];
|
|
58
|
+
|
|
59
|
+
// The panel mounts HTTP routes via Flow's `Router.flow()` macro, so serving
|
|
60
|
+
// only happens where Flow serves. `console` boots this provider anyway, but
|
|
61
|
+
// solely to register `make:admin-resource` — see `_servesHttp()`, which returns
|
|
62
|
+
// before any route mounting there.
|
|
63
|
+
static override environments: AppEnvironment[] = ["web", "test", "console"];
|
|
64
|
+
|
|
65
|
+
override onRegister(): void {
|
|
66
|
+
// Merge config/admin.ts (if present) into the panel configuration. This runs
|
|
67
|
+
// before any provider's onBooting, so the `plugins` opt-out is already in
|
|
68
|
+
// place by the time a contributor asks whether it is enabled — which is why
|
|
69
|
+
// that flag belongs in config/admin.ts rather than app/admin.ts.
|
|
70
|
+
try {
|
|
71
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
72
|
+
const adminCfg = config.get<Partial<AdminConfigShape>>("admin");
|
|
73
|
+
if (adminCfg) Panel.configure(adminCfg);
|
|
74
|
+
} catch {
|
|
75
|
+
// No config bound (e.g. in isolated tests) — defaults apply.
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// Publish the contribution surface. Bound here, in the registration phase, so
|
|
79
|
+
// every other provider can reach it from onBooting regardless of boot order —
|
|
80
|
+
// contributors deliberately do not (and must not) depend on this provider.
|
|
81
|
+
this.app.container.singleton("admin.panel", () => Panel.host());
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
private _disposeCountInvalidation: (() => void) | undefined;
|
|
85
|
+
|
|
86
|
+
override async onBooting(): Promise<void> {
|
|
87
|
+
// CLI generators first: `console` boots this provider only to register them,
|
|
88
|
+
// and returns before any of the serving work below.
|
|
89
|
+
this._registerCommands();
|
|
90
|
+
if (!this._servesHttp()) return;
|
|
91
|
+
|
|
92
|
+
await this._autodiscover();
|
|
93
|
+
this._watchModelChanges();
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* True only where the panel actually serves. `console`/`repl` boot this
|
|
98
|
+
* provider just for its generators — there is no `Router.flow()` there, and
|
|
99
|
+
* nothing to serve.
|
|
100
|
+
*/
|
|
101
|
+
private _servesHttp(): boolean {
|
|
102
|
+
const env = this.app.environment;
|
|
103
|
+
return env !== "console" && env !== "repl";
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Register the panel's generator. Lazy, so the command's module — and the stub
|
|
108
|
+
* text it carries — stays out of the serving path.
|
|
109
|
+
*/
|
|
110
|
+
private _registerCommands(): void {
|
|
111
|
+
const runner = this.app.container.tryMake("commands");
|
|
112
|
+
if (!runner) return;
|
|
113
|
+
runner.registerLazy("make:admin-resource", () =>
|
|
114
|
+
import("../commands/MakeAdminResourceCommand.ts").then((m) => m.MakeAdminResourceCommand),
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Mount the panel's routes.
|
|
120
|
+
*
|
|
121
|
+
* Deferred to `onBooted` because contributions arrive during the booting phase:
|
|
122
|
+
* every provider's `onBooting` has run by now, so the page registry is complete
|
|
123
|
+
* and each contributed page gets a route.
|
|
124
|
+
*/
|
|
125
|
+
override async onBooted(): Promise<void> {
|
|
126
|
+
if (!this._servesHttp()) return;
|
|
127
|
+
for (const panel of Panel.all()) {
|
|
128
|
+
this._registerRoutes(panel);
|
|
129
|
+
await this._registerAuthRoutes(panel);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* Mount a panel's auth pages when it called `auth({ enabled: true })`. Loaded
|
|
135
|
+
* via a dynamic import so the `@zerotal/auth` dependency stays optional
|
|
136
|
+
* otherwise.
|
|
137
|
+
*/
|
|
138
|
+
private async _registerAuthRoutes(panel: PanelInstance): Promise<void> {
|
|
139
|
+
if (!panel.authConfig()) return;
|
|
140
|
+
try {
|
|
141
|
+
const { registerAuthRoutes } = await import("../auth/register.ts");
|
|
142
|
+
registerAuthRoutes(panel.base(), this._effectiveGuard(panel.config().middleware));
|
|
143
|
+
} catch (err) {
|
|
144
|
+
frameworkLog("admin").warn(
|
|
145
|
+
"Auth pages require @zerotal/auth to be installed",
|
|
146
|
+
undefined,
|
|
147
|
+
err,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Invalidate a resource's cached tab counts whenever one of its records is
|
|
154
|
+
* created / updated / deleted — wherever that write originates (the admin,
|
|
155
|
+
* a controller, a seeder). Matches by model name, then backing table.
|
|
156
|
+
*/
|
|
157
|
+
private _watchModelChanges(): void {
|
|
158
|
+
this._disposeCountInvalidation = FrameworkEvents.on<ModelChanged>("ModelChanged", (e) => {
|
|
159
|
+
for (const panel of Panel.all()) {
|
|
160
|
+
for (const resource of panel.resources()) {
|
|
161
|
+
const model = resource.model as { name?: string; table?: string } | undefined;
|
|
162
|
+
if (model?.name === e.model || model?.table === e.table) {
|
|
163
|
+
void forgetTabCounts(resource.getSlug());
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
override async onStopping(): Promise<void> {
|
|
171
|
+
this._disposeCountInvalidation?.();
|
|
172
|
+
this._disposeCountInvalidation = undefined;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Load the app's panel wiring so its `Panel.register(...)` /
|
|
177
|
+
* `Panel.configure(...)` calls run.
|
|
178
|
+
*
|
|
179
|
+
* A single `app/admin.ts` is enough for a handful of resources. Past that,
|
|
180
|
+
* apps split the panel into `app/admin/` — a file per resource and an
|
|
181
|
+
* `index.ts` that registers them — so both spellings are honoured, the file
|
|
182
|
+
* first.
|
|
183
|
+
*/
|
|
184
|
+
private async _autodiscover(): Promise<void> {
|
|
185
|
+
const candidates = [`${process.cwd()}/app/admin.ts`, `${process.cwd()}/app/admin/index.ts`];
|
|
186
|
+
for (const file of candidates) {
|
|
187
|
+
try {
|
|
188
|
+
if (!(await Bun.file(file).exists())) continue;
|
|
189
|
+
await import(file);
|
|
190
|
+
return;
|
|
191
|
+
} catch (err) {
|
|
192
|
+
frameworkLog("admin").warn(`Failed to load ${file}`, { file }, err);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* Resolve the guard applied to panel routes. An explicit, non-empty
|
|
200
|
+
* `middleware` from config/admin.ts is used as-is. Otherwise the panel is
|
|
201
|
+
* default-denied in production-like environments via {@link AdminGuardMiddleware}
|
|
202
|
+
* — the secure default is closed. Set an explicit pass-through middleware to
|
|
203
|
+
* intentionally expose the panel without auth.
|
|
204
|
+
*/
|
|
205
|
+
private _effectiveGuard(configured: MiddlewareClass[] | undefined): MiddlewareClass[] {
|
|
206
|
+
if (configured && configured.length > 0) return configured;
|
|
207
|
+
return [AdminGuardMiddleware];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Mount the dashboard + a List page per resource under one panel's path. */
|
|
211
|
+
private _registerRoutes(panel: PanelInstance): void {
|
|
212
|
+
const path = panel.base();
|
|
213
|
+
// Guard every panel route with the configured middleware. When none is set,
|
|
214
|
+
// fall back to a fail-closed default guard that denies the panel in any
|
|
215
|
+
// production-like environment — so an app that forgets to configure auth
|
|
216
|
+
// does not silently expose full CRUD. See _effectiveGuard().
|
|
217
|
+
const guard: MiddlewareClass[] = this._effectiveGuard(panel.config().middleware);
|
|
218
|
+
|
|
219
|
+
const flow = (
|
|
220
|
+
Router as unknown as {
|
|
221
|
+
flow?: (p: string, page: unknown, mw?: MiddlewareClass[]) => unknown;
|
|
222
|
+
}
|
|
223
|
+
).flow;
|
|
224
|
+
if (typeof flow !== "function") {
|
|
225
|
+
throw new Error(
|
|
226
|
+
"[Zerotal Admin] Router.flow() is unavailable — register FlowProvider before AdminProvider.",
|
|
227
|
+
);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
Router.group({ prefix: path, middleware: guard }, () => {
|
|
231
|
+
flow("", makeDashboardPage(panel));
|
|
232
|
+
flow("/search", makeSearchPage(panel));
|
|
233
|
+
flow("/notifications", makeNotificationsPage(panel));
|
|
234
|
+
// Mounted only when the panel has somewhere to catalogue files; without a
|
|
235
|
+
// provider the page would be a permanent empty state.
|
|
236
|
+
if (panel.mediaProvider()) flow("/media", makeMediaPage(panel));
|
|
237
|
+
if (panel.roleProvider()) flow("/roles", makeRolesPage(panel));
|
|
238
|
+
|
|
239
|
+
// Leaving an impersonation. A plain GET rather than a page: it does one
|
|
240
|
+
// thing and sends you back, so there is nothing to render. Inside the
|
|
241
|
+
// group, so it carries the panel's prefix and guard already.
|
|
242
|
+
class StopImpersonating {
|
|
243
|
+
async handle(http: HttpContext): Promise<void> {
|
|
244
|
+
const { stopImpersonating } = await import("../impersonation.ts");
|
|
245
|
+
await stopImpersonating();
|
|
246
|
+
http.redirect(path || "/");
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
Router.get("/stop-impersonating", StopImpersonating, "handle");
|
|
250
|
+
|
|
251
|
+
// Custom pages — the app's own AdminPage subclasses and anything packages
|
|
252
|
+
// contributed. Each carries its declared ability as a second guard on top
|
|
253
|
+
// of the panel-wide one, so the route enforces exactly what the sidebar
|
|
254
|
+
// used to decide whether to draw the link.
|
|
255
|
+
for (const page of panel.registeredPages()) {
|
|
256
|
+
const pageGuard: MiddlewareClass[] = [
|
|
257
|
+
AdminAbilityMiddleware.with({ ability: page.ability }),
|
|
258
|
+
];
|
|
259
|
+
// A clustered page also answers to its cluster's ability.
|
|
260
|
+
if (page.cluster?.ability) {
|
|
261
|
+
pageGuard.push(AdminAbilityMiddleware.with({ ability: page.cluster.ability }));
|
|
262
|
+
}
|
|
263
|
+
const hosted = hostedPage(page.page);
|
|
264
|
+
const path = pagePath(page);
|
|
265
|
+
flow(`/${path}`, hosted, pageGuard);
|
|
266
|
+
for (const param of page.routeParams) {
|
|
267
|
+
flow(`/${path}/${param.replace(/^\//, "")}`, hosted, pageGuard);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Consoles — contributed table-and-action pages, rendered by the panel from
|
|
272
|
+
// the description the package handed over.
|
|
273
|
+
for (const console of panel.consoles()) {
|
|
274
|
+
flow(`/${console.slug}`, makeConsolePage(console, panel), [
|
|
275
|
+
AdminAbilityMiddleware.with({ ability: console.ability }),
|
|
276
|
+
]);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
for (const resource of panel.resources()) {
|
|
280
|
+
const slug = resource.getSlug();
|
|
281
|
+
const model = resource.getModelName();
|
|
282
|
+
// Where this resource's pages live: bare, inside a cluster, or under a
|
|
283
|
+
// parent record. The resource owns that decision — see `routePath()`.
|
|
284
|
+
const path = `/${resource.routePath()}`;
|
|
285
|
+
// A cluster's ability gates every route inside it, so a member never has
|
|
286
|
+
// to restate it. Resources carry their own record-level `can()` too.
|
|
287
|
+
const clusterGuard: MiddlewareClass[] = resource.cluster?.ability
|
|
288
|
+
? [AdminAbilityMiddleware.with({ ability: resource.cluster.ability })]
|
|
289
|
+
: [];
|
|
290
|
+
|
|
291
|
+
const fields = resource.isEditable() ? resource.form() : [];
|
|
292
|
+
if (resource.isEditable()) {
|
|
293
|
+
const create = makeResourceForm(fields, "create", `${model}CreateForm`);
|
|
294
|
+
const edit = makeResourceForm(fields, "edit", `${model}EditForm`);
|
|
295
|
+
// One shared page class serves every resource's Create/Edit route; it
|
|
296
|
+
// resolves which panel/resource/mode it is serving from the URL via
|
|
297
|
+
// this registry.
|
|
298
|
+
registerResourceForm(panel.id, slug, {
|
|
299
|
+
resource,
|
|
300
|
+
create: { FormClass: create.FormClass, fields: create.fields },
|
|
301
|
+
edit: { FormClass: edit.FormClass, fields: edit.fields },
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// A singular resource is one row: its index *is* the edit form, and it
|
|
306
|
+
// has no list, no create page and no id segment.
|
|
307
|
+
if (resource.singular) {
|
|
308
|
+
if (resource.isEditable()) flow(path, ResourceFormPage, clusterGuard);
|
|
309
|
+
continue;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
flow(path, makeResourceListPage(resource, panel), clusterGuard);
|
|
313
|
+
|
|
314
|
+
// The static `/create` route is registered before the `:id` view so it
|
|
315
|
+
// wins over the param segment.
|
|
316
|
+
if (resource.isEditable()) {
|
|
317
|
+
flow(`${path}/create`, ResourceFormPage, clusterGuard);
|
|
318
|
+
flow(
|
|
319
|
+
`${path}/:${resource.primaryKey}`,
|
|
320
|
+
makeRecordViewPage(resource, panel),
|
|
321
|
+
clusterGuard,
|
|
322
|
+
);
|
|
323
|
+
flow(`${path}/:${resource.primaryKey}/edit`, ResourceFormPage, clusterGuard);
|
|
324
|
+
} else {
|
|
325
|
+
flow(
|
|
326
|
+
`${path}/:${resource.primaryKey}`,
|
|
327
|
+
makeRecordViewPage(resource, panel),
|
|
328
|
+
clusterGuard,
|
|
329
|
+
);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
});
|
|
333
|
+
}
|
|
334
|
+
}
|