@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.
Files changed (77) hide show
  1. package/CHANGELOG.md +69 -0
  2. package/LICENSE +21 -0
  3. package/README.md +344 -0
  4. package/package.json +78 -0
  5. package/src/Cluster.ts +50 -0
  6. package/src/Panel.ts +288 -0
  7. package/src/PanelInstance.ts +644 -0
  8. package/src/Resource.ts +918 -0
  9. package/src/actions/Action.ts +607 -0
  10. package/src/actions/ImportRecordsJob.ts +108 -0
  11. package/src/actions/csv.ts +123 -0
  12. package/src/actions/index.ts +39 -0
  13. package/src/actions/render.tsx +181 -0
  14. package/src/actions/transfer.ts +307 -0
  15. package/src/actions/xlsx.ts +304 -0
  16. package/src/auth/AuthLayout.tsx +34 -0
  17. package/src/auth/index.ts +13 -0
  18. package/src/auth/pages/ForgotPasswordPage.tsx +87 -0
  19. package/src/auth/pages/LoginPage.tsx +121 -0
  20. package/src/auth/pages/ProfilePage.tsx +216 -0
  21. package/src/auth/pages/ResetPasswordPage.tsx +103 -0
  22. package/src/auth/pages/VerifyEmailPage.tsx +68 -0
  23. package/src/auth/register.ts +44 -0
  24. package/src/authRoles.ts +141 -0
  25. package/src/commands/MakeAdminResourceCommand.ts +181 -0
  26. package/src/config.ts +128 -0
  27. package/src/dashboardLayout.ts +101 -0
  28. package/src/databaseMedia.ts +148 -0
  29. package/src/databaseNotifications.ts +169 -0
  30. package/src/form/Field.ts +928 -0
  31. package/src/form/ResourceForm.ts +48 -0
  32. package/src/form/Section.ts +364 -0
  33. package/src/form/editors.ts +43 -0
  34. package/src/form/index.ts +59 -0
  35. package/src/history.ts +151 -0
  36. package/src/impersonation.ts +126 -0
  37. package/src/index.ts +380 -0
  38. package/src/infolist/Entry.ts +537 -0
  39. package/src/infolist/Section.ts +99 -0
  40. package/src/infolist/index.ts +38 -0
  41. package/src/media.ts +297 -0
  42. package/src/notifications.ts +65 -0
  43. package/src/pages/AdminPage.ts +100 -0
  44. package/src/pages/ConsolePage.tsx +324 -0
  45. package/src/pages/DashboardPage.tsx +264 -0
  46. package/src/pages/MediaPage.tsx +346 -0
  47. package/src/pages/NotificationsPage.tsx +155 -0
  48. package/src/pages/RecordViewPage.tsx +951 -0
  49. package/src/pages/ResourceFormPage.tsx +1856 -0
  50. package/src/pages/ResourceListPage.tsx +2552 -0
  51. package/src/pages/RolesPage.tsx +325 -0
  52. package/src/pages/SearchPage.tsx +169 -0
  53. package/src/plugin.ts +283 -0
  54. package/src/provider/AdminAbilityMiddleware.ts +25 -0
  55. package/src/provider/AdminGuardMiddleware.ts +29 -0
  56. package/src/provider/AdminProvider.ts +334 -0
  57. package/src/relations/RelationManager.ts +114 -0
  58. package/src/renderHooks.ts +86 -0
  59. package/src/roles.ts +175 -0
  60. package/src/savedViews.ts +79 -0
  61. package/src/support/ability.ts +73 -0
  62. package/src/support/authorize.ts +105 -0
  63. package/src/support/countCache.ts +37 -0
  64. package/src/support/hostPage.ts +30 -0
  65. package/src/table/Column.ts +353 -0
  66. package/src/table/Constraint.ts +238 -0
  67. package/src/table/Filter.ts +275 -0
  68. package/src/table/Group.ts +73 -0
  69. package/src/table/Tab.ts +77 -0
  70. package/src/testing.ts +121 -0
  71. package/src/theme.ts +70 -0
  72. package/src/ui/AdminLayout.tsx +355 -0
  73. package/src/ui/Breadcrumbs.tsx +84 -0
  74. package/src/ui/environmentIndicator.tsx +63 -0
  75. package/src/ui/icons.tsx +124 -0
  76. package/src/widgets/Widget.ts +251 -0
  77. 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
+ }