@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
@@ -0,0 +1,325 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Roles and permissions — a matrix of every ability the panel checks against
3
+ // every role, with the whole grid editable in place. Appears only when the panel
4
+ // has a role provider configured.
5
+
6
+ import { Component, expose } from "@zerotal/flow";
7
+ import type { HtmlNode } from "@zerotal/flow";
8
+ import { AdminLayout } from "../ui/AdminLayout.tsx";
9
+ import { Icon } from "../ui/icons.tsx";
10
+ import { Panel } from "../Panel.ts";
11
+ import type { PanelInstance } from "../PanelInstance.ts";
12
+ import type { Permission, Role } from "../roles.ts";
13
+ import { groupPermissions, panelPermissions, roleHas } from "../roles.ts";
14
+
15
+ export class RolesPage extends Component {
16
+ static layout = AdminLayout;
17
+ /** The panel this page belongs to — set by each generated subclass. */
18
+ static panel: PanelInstance;
19
+
20
+ /** A new role's details, while the create form is open. */
21
+ @expose creating = false;
22
+ @expose newName = "";
23
+ @expose newDescription = "";
24
+
25
+ private get _panel(): PanelInstance {
26
+ return (this.constructor as typeof RolesPage).panel ?? Panel.current();
27
+ }
28
+
29
+ private _roles: Role[] = [];
30
+ private _held: Record<string, string[]> = {};
31
+ private _permissions: Permission[] = [];
32
+
33
+ /**
34
+ * Turn one permission on or off for one role.
35
+ *
36
+ * The provider is given the role's complete new set rather than a delta, so a
37
+ * concurrent edit cannot leave a role holding half of two different answers.
38
+ */
39
+ @expose async toggle(roleId: unknown, key: unknown): Promise<void> {
40
+ const provider = this._panel.roleProvider();
41
+ const role = this._roles.find((r) => r.id === String(roleId));
42
+ if (!provider || !role) return;
43
+ // A superuser's permissions are not a set to edit — they are "everything".
44
+ if (role.superuser) {
45
+ this.flash(`${role.name} holds every permission by definition.`, "warning");
46
+ return;
47
+ }
48
+
49
+ const current = this._held[role.id] ?? [];
50
+ const permission = String(key);
51
+ const next = current.includes(permission)
52
+ ? current.filter((k) => k !== permission)
53
+ : [...current, permission];
54
+
55
+ await provider.setPermissions(role.id, next);
56
+ this._held[role.id] = next;
57
+ }
58
+
59
+ /** Grant or revoke a whole resource's abilities at once. */
60
+ @expose async toggleGroup(roleId: unknown, group: unknown): Promise<void> {
61
+ const provider = this._panel.roleProvider();
62
+ const role = this._roles.find((r) => r.id === String(roleId));
63
+ if (!provider || !role || role.superuser) return;
64
+
65
+ const keys = this._permissions.filter((p) => p.group === String(group)).map((p) => p.key);
66
+ const current = this._held[role.id] ?? [];
67
+ // Partly-ticked counts as off, so one click fills the group rather than
68
+ // clearing the few that were already set.
69
+ const all = keys.every((k) => current.includes(k));
70
+ const next = all
71
+ ? current.filter((k) => !keys.includes(k))
72
+ : [...new Set([...current, ...keys])];
73
+
74
+ await provider.setPermissions(role.id, next);
75
+ this._held[role.id] = next;
76
+ }
77
+
78
+ @expose openCreate(): void {
79
+ this.creating = true;
80
+ this.newName = "";
81
+ this.newDescription = "";
82
+ }
83
+
84
+ @expose cancelCreate(): void {
85
+ this.creating = false;
86
+ }
87
+
88
+ @expose async createRole(): Promise<void> {
89
+ const provider = this._panel.roleProvider();
90
+ if (!provider?.create || !this.newName.trim()) return;
91
+ await provider.create({
92
+ name: this.newName.trim(),
93
+ ...(this.newDescription.trim() ? { description: this.newDescription.trim() } : {}),
94
+ });
95
+ this.creating = false;
96
+ this.flash(`${this.newName.trim()} created.`, "success");
97
+ }
98
+
99
+ @expose async removeRole(roleId: unknown): Promise<void> {
100
+ const provider = this._panel.roleProvider();
101
+ const role = this._roles.find((r) => r.id === String(roleId));
102
+ if (!provider?.remove || !role) return;
103
+ await provider.remove(role.id);
104
+ this.flash(`${role.name} deleted.`, "success");
105
+ }
106
+
107
+ override async render(): Promise<HtmlNode> {
108
+ const provider = this._panel.roleProvider();
109
+ const base = this._panel.base();
110
+
111
+ this._permissions = panelPermissions(this._panel);
112
+ this._roles = provider ? await provider.list() : [];
113
+ this._held = {};
114
+ for (const [id, keys] of await Promise.all(
115
+ this._roles.map(async (r) => [r.id, await provider!.permissionsFor(r.id)] as const),
116
+ )) {
117
+ this._held[id] = keys;
118
+ }
119
+
120
+ const groups = groupPermissions(this._permissions);
121
+ const canCreate = !!provider?.create;
122
+ const canRemove = !!provider?.remove;
123
+
124
+ return (
125
+ <div class="space-y-6">
126
+ <div class="flex flex-wrap items-end justify-between gap-4">
127
+ <div>
128
+ <nav class="mb-1 text-xs text-muted-foreground">
129
+ <a href={base} navigate class="hover:text-foreground">
130
+ Dashboard
131
+ </a>
132
+ <span class="mx-1">/</span>
133
+ <span>Roles</span>
134
+ </nav>
135
+ <h1 class="text-2xl font-semibold tracking-tight">Roles &amp; permissions</h1>
136
+ <p class="mt-1 text-sm text-muted-foreground">
137
+ Every ability this panel checks, and which roles hold it. Changes apply immediately.
138
+ </p>
139
+ </div>
140
+ {canCreate && !this.creating ? (
141
+ <button
142
+ type="button"
143
+ onClick={this.openCreate}
144
+ class="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90"
145
+ >
146
+ <Icon name="plus" class="h-4 w-4" />
147
+ New role
148
+ </button>
149
+ ) : null}
150
+ </div>
151
+
152
+ {this.creating ? (
153
+ <div class="space-y-3 rounded-lg border border-border bg-card p-4">
154
+ <div class="grid gap-3 sm:grid-cols-2">
155
+ <label class="block">
156
+ <span class="mb-1 block text-xs font-medium">Name</span>
157
+ <input
158
+ {...{ "flow:model": "newName" }}
159
+ placeholder="Editor"
160
+ class="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
161
+ />
162
+ </label>
163
+ <label class="block">
164
+ <span class="mb-1 block text-xs font-medium">Description</span>
165
+ <input
166
+ {...{ "flow:model": "newDescription" }}
167
+ placeholder="Can publish, but not delete"
168
+ class="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus:ring-2 focus:ring-ring"
169
+ />
170
+ </label>
171
+ </div>
172
+ <div class="flex items-center gap-2">
173
+ <button
174
+ type="button"
175
+ onClick={this.createRole}
176
+ class="inline-flex h-9 items-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition hover:opacity-90"
177
+ >
178
+ Create
179
+ </button>
180
+ <button
181
+ type="button"
182
+ onClick={this.cancelCreate}
183
+ class="inline-flex h-9 items-center rounded-lg border border-input px-4 text-sm font-medium transition hover:bg-accent"
184
+ >
185
+ Cancel
186
+ </button>
187
+ </div>
188
+ </div>
189
+ ) : null}
190
+
191
+ {!provider ? (
192
+ <div class="rounded-lg border border-dashed border-border p-8 text-center text-sm text-muted-foreground">
193
+ No role provider is configured for this panel.
194
+ </div>
195
+ ) : this._roles.length === 0 ? (
196
+ <div class="rounded-lg border border-dashed border-border p-10 text-center">
197
+ <Icon name="shield" class="mx-auto h-8 w-8 text-muted-foreground" />
198
+ <p class="mt-3 text-sm font-medium">No roles yet</p>
199
+ <p class="mt-1 text-sm text-muted-foreground">
200
+ Create one, and every ability below becomes assignable.
201
+ </p>
202
+ </div>
203
+ ) : (
204
+ <div class="overflow-x-auto rounded-lg border border-border">
205
+ <table class="w-full text-sm">
206
+ <thead class="bg-muted/40">
207
+ <tr>
208
+ <th
209
+ scope="col"
210
+ class="sticky left-0 z-10 bg-muted/40 px-3 py-2 text-left font-medium"
211
+ >
212
+ Permission
213
+ </th>
214
+ {this._roles.map((role) => (
215
+ <th scope="col" class="px-3 py-2 text-center font-medium">
216
+ <div class="flex flex-col items-center gap-0.5">
217
+ <span class="flex items-center gap-1">
218
+ {role.superuser ? (
219
+ <Icon name="shield" class="h-3.5 w-3.5 text-primary" />
220
+ ) : null}
221
+ {role.name}
222
+ </span>
223
+ {role.description ? (
224
+ <span class="text-[11px] font-normal text-muted-foreground">
225
+ {role.description}
226
+ </span>
227
+ ) : null}
228
+ {canRemove && !role.superuser ? (
229
+ <button
230
+ type="button"
231
+ onClick={this.removeRole}
232
+ data-args={JSON.stringify([role.id])}
233
+ confirm={`Delete the ${role.name} role? Anyone holding it loses its permissions.`}
234
+ class="text-[11px] font-normal text-destructive hover:underline"
235
+ >
236
+ Delete
237
+ </button>
238
+ ) : null}
239
+ </div>
240
+ </th>
241
+ ))}
242
+ </tr>
243
+ </thead>
244
+ <tbody>
245
+ {groups.map((group) => (
246
+ <>
247
+ <tr class="border-t border-border bg-muted/20">
248
+ <th
249
+ scope="rowgroup"
250
+ class="sticky left-0 z-10 bg-muted/20 px-3 py-1.5 text-left text-xs font-semibold uppercase tracking-wide text-muted-foreground"
251
+ >
252
+ {group.group}
253
+ </th>
254
+ {this._roles.map((role) => {
255
+ const keys = group.items.map((p) => p.key);
256
+ const held = this._held[role.id] ?? [];
257
+ const all = role.superuser || keys.every((k) => held.includes(k));
258
+ return (
259
+ <td class="px-3 py-1.5 text-center">
260
+ {/* One click for the whole block — the common case is
261
+ granting a role a resource, not one ability of it. */}
262
+ <button
263
+ type="button"
264
+ onClick={this.toggleGroup}
265
+ data-args={JSON.stringify([role.id, group.group])}
266
+ disabled={role.superuser}
267
+ aria-label={`${all ? "Revoke" : "Grant"} all ${group.group} permissions for ${role.name}`}
268
+ class="text-[11px] text-muted-foreground transition hover:text-foreground disabled:opacity-40"
269
+ >
270
+ {all ? "none" : "all"}
271
+ </button>
272
+ </td>
273
+ );
274
+ })}
275
+ </tr>
276
+ {group.items.map((permission) => (
277
+ <tr class="border-t border-border hover:bg-muted/30">
278
+ <td class="sticky left-0 z-10 bg-card px-3 py-1.5">
279
+ <span>{permission.label}</span>
280
+ <span class="ml-2 font-mono text-[11px] text-muted-foreground">
281
+ {permission.key}
282
+ </span>
283
+ </td>
284
+ {this._roles.map((role) => {
285
+ const on = roleHas(role, this._held[role.id] ?? [], permission.key);
286
+ return (
287
+ <td class="px-3 py-1.5 text-center">
288
+ <button
289
+ type="button"
290
+ onClick={this.toggle}
291
+ data-args={JSON.stringify([role.id, permission.key])}
292
+ disabled={role.superuser}
293
+ role="switch"
294
+ aria-checked={on ? "true" : "false"}
295
+ aria-label={`${permission.label} for ${role.name}`}
296
+ class={`inline-flex h-5 w-5 items-center justify-center rounded border transition disabled:opacity-40 ${
297
+ on
298
+ ? "border-primary bg-primary text-primary-foreground"
299
+ : "border-input hover:border-primary/50"
300
+ }`}
301
+ >
302
+ {on ? <Icon name="check" class="h-3.5 w-3.5" /> : null}
303
+ </button>
304
+ </td>
305
+ );
306
+ })}
307
+ </tr>
308
+ ))}
309
+ </>
310
+ ))}
311
+ </tbody>
312
+ </table>
313
+ </div>
314
+ )}
315
+ </div>
316
+ );
317
+ }
318
+ }
319
+
320
+ /** Build a RolesPage bound to one panel. */
321
+ export function makeRolesPage(panel: PanelInstance = Panel.default()): typeof RolesPage {
322
+ return class BoundRolesPage extends RolesPage {
323
+ static override panel = panel;
324
+ };
325
+ }
@@ -0,0 +1,169 @@
1
+ /** @jsxImportSource @zerotal/flow */
2
+ // Global search. Queries every globally-searchable resource for the `?q=` term
3
+ // and groups the matches, each linking to its View page. Reuses each resource's
4
+ // `records({ search })` (its searchable columns), so there's no separate search
5
+ // index to maintain.
6
+
7
+ import { Component, url } from "@zerotal/flow";
8
+ import type { HtmlNode } from "@zerotal/flow";
9
+ import { AdminLayout, makeAdminLayout } from "../ui/AdminLayout.tsx";
10
+ import { Icon } from "../ui/icons.tsx";
11
+ import { Panel } from "../Panel.ts";
12
+ import type { PanelInstance } from "../PanelInstance.ts";
13
+
14
+ const PER_RESOURCE = 5;
15
+
16
+ export class SearchPage extends Component {
17
+ static layout = AdminLayout;
18
+ /** The panel this page belongs to — set by each generated subclass. */
19
+ static panel: PanelInstance;
20
+
21
+ @url q = "";
22
+
23
+ private get _panel(): PanelInstance {
24
+ return (this.constructor as typeof SearchPage).panel ?? Panel.current();
25
+ }
26
+
27
+ override async render(): Promise<HtmlNode> {
28
+ const panel = this._panel;
29
+ const base = panel.base();
30
+ const term = this.q.trim();
31
+
32
+ const groups = term
33
+ ? (
34
+ await Promise.all(
35
+ panel
36
+ .resources()
37
+ .filter((r) => r.globallySearchable())
38
+ .map(async (r) => {
39
+ const result = await r.records({ search: term, perPage: PER_RESOURCE });
40
+ return { resource: r, rows: result.rows, total: result.total };
41
+ }),
42
+ )
43
+ ).filter((g) => g.rows.length > 0)
44
+ : [];
45
+
46
+ // Contributed sources — jobs, log lines, audit entries, anything a package
47
+ // can address by URL. A provider that throws is dropped rather than taking
48
+ // the whole results page down with it.
49
+ const contributed = term
50
+ ? (
51
+ await Promise.all(
52
+ (await panel.visibleSearchProviders()).map(async (p) => {
53
+ try {
54
+ return { provider: p, hits: (await p.search(term)).slice(0, PER_RESOURCE) };
55
+ } catch {
56
+ return { provider: p, hits: [] };
57
+ }
58
+ }),
59
+ )
60
+ ).filter((g) => g.hits.length > 0)
61
+ : [];
62
+
63
+ const totalHits =
64
+ groups.reduce((n, g) => n + g.total, 0) + contributed.reduce((n, g) => n + g.hits.length, 0);
65
+
66
+ return (
67
+ <div class="mx-auto w-full max-w-3xl space-y-6">
68
+ <div>
69
+ <h1 class="text-2xl font-semibold tracking-tight">Search</h1>
70
+ {term ? (
71
+ <p class="mt-1 text-sm text-muted-foreground">
72
+ {totalHits} {totalHits === 1 ? "result" : "results"} for “{term}”
73
+ </p>
74
+ ) : (
75
+ <p class="mt-1 text-sm text-muted-foreground">
76
+ Type a query in the search box above to look across your resources.
77
+ </p>
78
+ )}
79
+ </div>
80
+
81
+ {term && groups.length === 0 && contributed.length === 0 ? (
82
+ <div class="rounded-xl border border-dashed border-border p-12 text-center">
83
+ <div class="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-muted text-muted-foreground">
84
+ <Icon name="search" class="h-6 w-6" />
85
+ </div>
86
+ <p class="mt-3 text-sm font-medium">No matches</p>
87
+ <p class="mt-1 text-sm text-muted-foreground">Nothing matched “{term}”.</p>
88
+ </div>
89
+ ) : null}
90
+
91
+ {groups.map((g) => (
92
+ <div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
93
+ <div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
94
+ <Icon name={g.resource.navigationIcon} class="h-4 w-4 text-muted-foreground" />
95
+ <span class="text-sm font-semibold">{g.resource.getPluralLabel()}</span>
96
+ <span class="text-xs text-muted-foreground">({g.total})</span>
97
+ </div>
98
+ <ul class="divide-y divide-border">
99
+ {g.rows.map((row) => (
100
+ <li>
101
+ <a
102
+ href={`${base}/${g.resource.getSlug()}/${String(row[g.resource.primaryKey])}`}
103
+ navigate
104
+ class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition hover:bg-accent"
105
+ >
106
+ <span class="truncate">{g.resource.recordTitle(row)}</span>
107
+ <Icon name="chevron-right" class="h-4 w-4 shrink-0 text-muted-foreground" />
108
+ </a>
109
+ </li>
110
+ ))}
111
+ {g.total > g.rows.length ? (
112
+ <li>
113
+ <a
114
+ href={`${base}/${g.resource.getSlug()}?search=${encodeURIComponent(term)}`}
115
+ navigate
116
+ class="block px-4 py-2 text-xs font-medium text-primary hover:underline"
117
+ >
118
+ See all {g.total} {g.resource.getPluralLabel().toLowerCase()} →
119
+ </a>
120
+ </li>
121
+ ) : null}
122
+ </ul>
123
+ </div>
124
+ ))}
125
+
126
+ {contributed.map((g) => (
127
+ <div class="rounded-xl border border-border bg-card text-card-foreground shadow-sm">
128
+ <div class="flex items-center gap-2 border-b border-border px-4 py-2.5">
129
+ <Icon name={g.provider.icon ?? "search"} class="h-4 w-4 text-muted-foreground" />
130
+ <span class="text-sm font-semibold">{g.provider.label}</span>
131
+ <span class="text-xs text-muted-foreground">({g.hits.length})</span>
132
+ </div>
133
+ <ul class="divide-y divide-border">
134
+ {g.hits.map((hit) => (
135
+ <li>
136
+ <a
137
+ href={hit.href}
138
+ navigate
139
+ class="flex items-center justify-between gap-3 px-4 py-2.5 text-sm transition hover:bg-accent"
140
+ >
141
+ <span class="min-w-0">
142
+ <span class="block truncate">{hit.label}</span>
143
+ {hit.description ? (
144
+ <span class="block truncate text-xs text-muted-foreground">
145
+ {hit.description}
146
+ </span>
147
+ ) : null}
148
+ </span>
149
+ <Icon name="chevron-right" class="h-4 w-4 shrink-0 text-muted-foreground" />
150
+ </a>
151
+ </li>
152
+ ))}
153
+ </ul>
154
+ </div>
155
+ ))}
156
+ </div>
157
+ );
158
+ }
159
+ }
160
+
161
+ /** The search page for one panel, searching only that panel's resources. */
162
+ export function makeSearchPage(panel: PanelInstance = Panel.default()): typeof SearchPage {
163
+ const Page = class extends SearchPage {
164
+ static override panel = panel;
165
+ static override layout = makeAdminLayout(panel);
166
+ };
167
+ Object.defineProperty(Page, "name", { value: "SearchPage" });
168
+ return Page;
169
+ }