@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,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Relation managers — related-record tables shown on a record's View page.
|
|
3
|
+
* A HasMany relation renders the children as a table that links into their own
|
|
4
|
+
* resource, so full CRUD lives there rather than being duplicated here.
|
|
5
|
+
*
|
|
6
|
+
* class UserResource extends Resource {
|
|
7
|
+
* static relations() {
|
|
8
|
+
* return [hasMany(PostResource, "user_id").title("Posts")];
|
|
9
|
+
* }
|
|
10
|
+
* }
|
|
11
|
+
*/
|
|
12
|
+
import type { ResourceClass } from "../Panel.ts";
|
|
13
|
+
|
|
14
|
+
export type RelationKind = "hasMany" | "belongsToMany";
|
|
15
|
+
|
|
16
|
+
/** A pivot-table column surfaced on a BelongsToMany relation table. */
|
|
17
|
+
export interface PivotColumn {
|
|
18
|
+
key: string;
|
|
19
|
+
label?: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class RelationManager {
|
|
23
|
+
/** @internal */ _resource: ResourceClass;
|
|
24
|
+
/** @internal HasMany: the child's foreign-key column. */
|
|
25
|
+
_foreignKey: string;
|
|
26
|
+
/** @internal */ _kind: RelationKind = "hasMany";
|
|
27
|
+
/** @internal BelongsToMany: the relationship method on the parent model. */
|
|
28
|
+
_relationName?: string;
|
|
29
|
+
/** @internal BelongsToMany: pivot columns to display. */
|
|
30
|
+
_pivotColumns: PivotColumn[] = [];
|
|
31
|
+
/** @internal BelongsToMany: max related options offered in the Attach select. */
|
|
32
|
+
_attachLimit = 50;
|
|
33
|
+
/** @internal */ _title?: string;
|
|
34
|
+
/** @internal */ _icon?: string;
|
|
35
|
+
/** @internal */ _perPage = 10;
|
|
36
|
+
/** @internal */ _canCreate = true;
|
|
37
|
+
/** @internal BelongsToMany: allow attach/detach. */
|
|
38
|
+
_canAttach = true;
|
|
39
|
+
|
|
40
|
+
constructor(resource: ResourceClass, foreignKey: string) {
|
|
41
|
+
this._resource = resource;
|
|
42
|
+
this._foreignKey = foreignKey;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
title(title: string): this {
|
|
46
|
+
this._title = title;
|
|
47
|
+
return this;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Disable the "New" button for this relation (HasMany). */
|
|
51
|
+
canCreate(value: boolean): this {
|
|
52
|
+
this._canCreate = value;
|
|
53
|
+
return this;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Disable attach/detach (BelongsToMany). */
|
|
57
|
+
canAttach(value: boolean): this {
|
|
58
|
+
this._canAttach = value;
|
|
59
|
+
return this;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
icon(name: string): this {
|
|
63
|
+
this._icon = name;
|
|
64
|
+
return this;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
perPage(n: number): this {
|
|
68
|
+
this._perPage = Math.max(1, n);
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Pivot columns to show on a BelongsToMany table (read from `row.pivot`). */
|
|
73
|
+
pivotColumns(columns: PivotColumn[]): this {
|
|
74
|
+
this._pivotColumns = columns;
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Cap the number of options listed in the Attach select. */
|
|
79
|
+
attachLimit(n: number): this {
|
|
80
|
+
this._attachLimit = Math.max(1, n);
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
isBelongsToMany(): boolean {
|
|
85
|
+
return this._kind === "belongsToMany";
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
getTitle(): string {
|
|
89
|
+
return this._title ?? this._resource.getPluralLabel();
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** A HasMany relation manager (children referencing the parent via `foreignKey`). */
|
|
94
|
+
export function hasMany(resource: ResourceClass, foreignKey: string): RelationManager {
|
|
95
|
+
return new RelationManager(resource, foreignKey);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* A BelongsToMany relation manager. `relationName` is the relationship method on
|
|
100
|
+
* the *parent* model (e.g. `"roles"`) — its `attach()`/`detach()`/`get()` drive
|
|
101
|
+
* the pivot. Renders the attached records with Detach + an Attach picker.
|
|
102
|
+
*
|
|
103
|
+
* class UserResource extends Resource {
|
|
104
|
+
* static relations() {
|
|
105
|
+
* return [belongsToMany(RoleResource, "roles").pivotColumns([{ key: "assigned_at" }])];
|
|
106
|
+
* }
|
|
107
|
+
* }
|
|
108
|
+
*/
|
|
109
|
+
export function belongsToMany(resource: ResourceClass, relationName: string): RelationManager {
|
|
110
|
+
const rm = new RelationManager(resource, "");
|
|
111
|
+
rm._kind = "belongsToMany";
|
|
112
|
+
rm._relationName = relationName;
|
|
113
|
+
return rm;
|
|
114
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Render hooks — named positions in the panel's chrome that anything can render
|
|
3
|
+
* into, without owning the page it appears on.
|
|
4
|
+
*
|
|
5
|
+
* A contributed page can only add a *page*. A hook can add a banner above every
|
|
6
|
+
* table, a badge beside the brand, a compliance notice under every form — the
|
|
7
|
+
* small insertions that otherwise force a fork of the layout:
|
|
8
|
+
*
|
|
9
|
+
* Panel.renderHook("page.header.end", () => <TrialBanner />);
|
|
10
|
+
* Panel.renderHook("table.start", (ctx) =>
|
|
11
|
+
* ctx.resource === "orders" ? <ShippingNotice /> : null,
|
|
12
|
+
* );
|
|
13
|
+
*
|
|
14
|
+
* A hook returning `null` renders nothing, which is what makes conditional
|
|
15
|
+
* placement practical: register once, decide per render.
|
|
16
|
+
*/
|
|
17
|
+
import type { HtmlNode } from "@zerotal/flow";
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Where a hook renders. Named for the position rather than the markup, so the
|
|
21
|
+
* panel's internals can change without breaking a registration.
|
|
22
|
+
*/
|
|
23
|
+
export type RenderHookName =
|
|
24
|
+
/** Immediately inside the shell, above everything. */
|
|
25
|
+
| "body.start"
|
|
26
|
+
/** At the very end of the shell. */
|
|
27
|
+
| "body.end"
|
|
28
|
+
/** Beside the brand, at the top of the sidebar. */
|
|
29
|
+
| "sidebar.start"
|
|
30
|
+
/** Below the navigation, at the foot of the sidebar. */
|
|
31
|
+
| "sidebar.end"
|
|
32
|
+
/** In the top bar, before the panel's own controls. */
|
|
33
|
+
| "topbar.start"
|
|
34
|
+
/** In the top bar, after the notification bell and theme toggle. */
|
|
35
|
+
| "topbar.end"
|
|
36
|
+
/** Above a page's heading. */
|
|
37
|
+
| "page.header.start"
|
|
38
|
+
/** Below a page's heading and actions. */
|
|
39
|
+
| "page.header.end"
|
|
40
|
+
/** Directly above a resource's table. */
|
|
41
|
+
| "table.start"
|
|
42
|
+
/** Directly below a resource's table. */
|
|
43
|
+
| "table.end"
|
|
44
|
+
/** Above a create/edit form's fields. */
|
|
45
|
+
| "form.start"
|
|
46
|
+
/** Below a form's fields, above its buttons. */
|
|
47
|
+
| "form.end"
|
|
48
|
+
/** Above a record's infolist. */
|
|
49
|
+
| "record.start"
|
|
50
|
+
/** Below a record's infolist. */
|
|
51
|
+
| "record.end";
|
|
52
|
+
|
|
53
|
+
/** What a hook knows about where it is rendering. */
|
|
54
|
+
export interface RenderHookContext {
|
|
55
|
+
/** Slug of the resource being rendered, when there is one. */
|
|
56
|
+
resource?: string | undefined;
|
|
57
|
+
/** Which screen: the list, a record, a form, or the dashboard. */
|
|
58
|
+
page?: "list" | "record" | "form" | "dashboard" | undefined;
|
|
59
|
+
/** The record's id, on a record or edit screen. */
|
|
60
|
+
recordId?: string | undefined;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type RenderHook = (context: RenderHookContext) => HtmlNode | string | null;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Resolve every hook registered at `name`, dropping the ones that declined to
|
|
67
|
+
* render and the ones that threw.
|
|
68
|
+
*
|
|
69
|
+
* A hook is decoration: it must not be able to take down the page it decorates,
|
|
70
|
+
* so a throwing hook is logged and skipped rather than propagated.
|
|
71
|
+
*/
|
|
72
|
+
export function resolveRenderHooks(
|
|
73
|
+
hooks: RenderHook[],
|
|
74
|
+
context: RenderHookContext = {},
|
|
75
|
+
): (HtmlNode | string)[] {
|
|
76
|
+
const out: (HtmlNode | string)[] = [];
|
|
77
|
+
for (const hook of hooks) {
|
|
78
|
+
try {
|
|
79
|
+
const node = hook(context);
|
|
80
|
+
if (node !== null && node !== undefined) out.push(node);
|
|
81
|
+
} catch (error) {
|
|
82
|
+
console.error("[Zerotal Admin] render hook failed:", error);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return out;
|
|
86
|
+
}
|
package/src/roles.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Roles and permissions, as something you can see and change.
|
|
3
|
+
*
|
|
4
|
+
* Authorization already works without this: a resource's `can()` answers every
|
|
5
|
+
* question the panel asks, usually by delegating to a gate. What is missing is
|
|
6
|
+
* the other direction — being able to look at who can do what, and change it,
|
|
7
|
+
* without editing code or writing SQL by hand.
|
|
8
|
+
*
|
|
9
|
+
* Two halves make that possible.
|
|
10
|
+
*
|
|
11
|
+
* The **catalogue** is derived, not declared. {@link panelPermissions} walks the
|
|
12
|
+
* registered resources, pages and actions and reports every ability the panel
|
|
13
|
+
* actually checks. That matters because a hand-maintained permission list drifts
|
|
14
|
+
* the moment somebody adds a resource, and a matrix missing a row is worse than
|
|
15
|
+
* no matrix — it quietly suggests a permission does not exist.
|
|
16
|
+
*
|
|
17
|
+
* The **assignments** are the app's, through a {@link RoleProvider}. Where roles
|
|
18
|
+
* live and how a user is attached to one differs per application, and the panel
|
|
19
|
+
* has no business assuming a schema:
|
|
20
|
+
*
|
|
21
|
+
* Panel.roles({ list, permissionsFor, setPermissions });
|
|
22
|
+
*
|
|
23
|
+
* With no provider configured the page does not appear, and authorization keeps
|
|
24
|
+
* working exactly as it did.
|
|
25
|
+
*/
|
|
26
|
+
import type { PanelInstance } from "./PanelInstance.ts";
|
|
27
|
+
import { Action, ActionGroup } from "./actions/Action.ts";
|
|
28
|
+
|
|
29
|
+
/** One thing a role may be permitted to do. */
|
|
30
|
+
export interface Permission {
|
|
31
|
+
/** `products.update` — what a `can()` check would be asked. */
|
|
32
|
+
key: string;
|
|
33
|
+
/** Human label for the matrix cell's row. */
|
|
34
|
+
label: string;
|
|
35
|
+
/** Which resource or page this belongs to, for grouping the matrix. */
|
|
36
|
+
group: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A named set of permissions. */
|
|
40
|
+
export interface Role {
|
|
41
|
+
id: string;
|
|
42
|
+
name: string;
|
|
43
|
+
/** Optional description, shown under the name. */
|
|
44
|
+
description?: string;
|
|
45
|
+
/**
|
|
46
|
+
* A role that holds every permission, present and future.
|
|
47
|
+
*
|
|
48
|
+
* Worth modelling explicitly rather than by ticking every box: an
|
|
49
|
+
* administrator should not silently lose access to a resource added next week.
|
|
50
|
+
*/
|
|
51
|
+
superuser?: boolean;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** What the app supplies so roles can be listed and edited. */
|
|
55
|
+
export interface RoleProvider {
|
|
56
|
+
/** Every role, in the order they should appear. */
|
|
57
|
+
list(): Promise<Role[]> | Role[];
|
|
58
|
+
/** The permission keys one role currently holds. */
|
|
59
|
+
permissionsFor(roleId: string): Promise<string[]> | string[];
|
|
60
|
+
/** Replace a role's permissions with exactly this set. */
|
|
61
|
+
setPermissions(roleId: string, keys: string[]): Promise<void> | void;
|
|
62
|
+
/** Create a role. Omit to make roles read-only in the panel. */
|
|
63
|
+
create?(role: Omit<Role, "id">): Promise<void> | void;
|
|
64
|
+
/** Delete a role. Omit to forbid deletion from the panel. */
|
|
65
|
+
remove?(roleId: string): Promise<void> | void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The abilities every resource is checked for, whatever else it declares. */
|
|
69
|
+
const RESOURCE_ABILITIES: { suffix: string; label: string }[] = [
|
|
70
|
+
{ suffix: "viewAny", label: "List" },
|
|
71
|
+
{ suffix: "view", label: "View" },
|
|
72
|
+
{ suffix: "create", label: "Create" },
|
|
73
|
+
{ suffix: "update", label: "Edit" },
|
|
74
|
+
{ suffix: "delete", label: "Delete" },
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** The soft-delete abilities, only meaningful where the model has them. */
|
|
78
|
+
const TRASH_ABILITIES: { suffix: string; label: string }[] = [
|
|
79
|
+
{ suffix: "restore", label: "Restore" },
|
|
80
|
+
{ suffix: "forceDelete", label: "Delete permanently" },
|
|
81
|
+
];
|
|
82
|
+
|
|
83
|
+
/** Turn an action key into a readable label. */
|
|
84
|
+
function titleCase(key: string): string {
|
|
85
|
+
return key
|
|
86
|
+
.replace(/[_-]+/g, " ")
|
|
87
|
+
.replace(/([a-z])([A-Z])/g, "$1 $2")
|
|
88
|
+
.replace(/\b\w/g, (c) => c.toUpperCase())
|
|
89
|
+
.trim();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Every action a resource offers, with groups flattened into their members. */
|
|
93
|
+
function actionsOf(resource: {
|
|
94
|
+
recordActions(): unknown[];
|
|
95
|
+
headerActions(): unknown[];
|
|
96
|
+
bulkActions(): unknown[];
|
|
97
|
+
}): Action[] {
|
|
98
|
+
const flatten = (items: unknown[]): Action[] =>
|
|
99
|
+
items.flatMap((item) =>
|
|
100
|
+
item instanceof ActionGroup
|
|
101
|
+
? (item._actions as Action[])
|
|
102
|
+
: item instanceof Action
|
|
103
|
+
? [item]
|
|
104
|
+
: [],
|
|
105
|
+
);
|
|
106
|
+
return [
|
|
107
|
+
...flatten(resource.recordActions()),
|
|
108
|
+
...flatten(resource.headerActions()),
|
|
109
|
+
...flatten(resource.bulkActions()),
|
|
110
|
+
];
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Every permission this panel checks, derived from what it has registered.
|
|
115
|
+
*
|
|
116
|
+
* Grouped by resource so the matrix reads as one block per thing being
|
|
117
|
+
* administered. Custom actions come last within their group, because the five
|
|
118
|
+
* standard abilities are what someone scans for first.
|
|
119
|
+
*/
|
|
120
|
+
export function panelPermissions(panel: PanelInstance): Permission[] {
|
|
121
|
+
const out: Permission[] = [];
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
const add = (key: string, label: string, group: string): void => {
|
|
124
|
+
if (seen.has(key)) return;
|
|
125
|
+
seen.add(key);
|
|
126
|
+
out.push({ key, label, group });
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
for (const resource of panel.resources()) {
|
|
130
|
+
const slug = resource.getSlug();
|
|
131
|
+
const group = resource.getPluralLabel();
|
|
132
|
+
|
|
133
|
+
for (const { suffix, label } of RESOURCE_ABILITIES) add(`${slug}.${suffix}`, label, group);
|
|
134
|
+
if (resource.usesSoftDeletes()) {
|
|
135
|
+
for (const { suffix, label } of TRASH_ABILITIES) add(`${slug}.${suffix}`, label, group);
|
|
136
|
+
}
|
|
137
|
+
// An action's key is what `can()` is asked for it, so it belongs in the
|
|
138
|
+
// matrix on the same footing as the standard abilities.
|
|
139
|
+
for (const action of actionsOf(resource)) {
|
|
140
|
+
add(`${slug}.${action._key}`, action.getLabel(), group);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Pages carry their own ability when they declare one; that is the only thing
|
|
145
|
+
// guarding them, so it must be visible here.
|
|
146
|
+
for (const page of panel.registeredPages()) {
|
|
147
|
+
const ability = page.ability;
|
|
148
|
+
if (ability) add(ability, titleCase(ability.split(".").pop() ?? ability), "Pages");
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Group a flat permission list for rendering, preserving order. */
|
|
155
|
+
export function groupPermissions(
|
|
156
|
+
permissions: Permission[],
|
|
157
|
+
): { group: string; items: Permission[] }[] {
|
|
158
|
+
const groups: { group: string; items: Permission[] }[] = [];
|
|
159
|
+
for (const permission of permissions) {
|
|
160
|
+
const existing = groups.find((g) => g.group === permission.group);
|
|
161
|
+
if (existing) existing.items.push(permission);
|
|
162
|
+
else groups.push({ group: permission.group, items: [permission] });
|
|
163
|
+
}
|
|
164
|
+
return groups;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Whether a role holds a permission.
|
|
169
|
+
*
|
|
170
|
+
* A superuser holds everything by definition, which is what keeps the matrix
|
|
171
|
+
* honest about what such a role can actually do.
|
|
172
|
+
*/
|
|
173
|
+
export function roleHas(role: Role, held: string[], key: string): boolean {
|
|
174
|
+
return role.superuser === true || held.includes(key);
|
|
175
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Saved views — a list, the way someone left it.
|
|
3
|
+
*
|
|
4
|
+
* Every bit of list state already lives in the URL: search, filters, tab, sort,
|
|
5
|
+
* column visibility, grouping, page size. Saving a view is therefore saving a
|
|
6
|
+
* query string, and restoring one is a link. That is the whole idea.
|
|
7
|
+
*
|
|
8
|
+
* Persistence is the app's, for the same reason the notification centre's is:
|
|
9
|
+
* where a view belongs — a table, a user preference blob, local config — depends
|
|
10
|
+
* on the application, not on the panel.
|
|
11
|
+
*
|
|
12
|
+
* Panel.savedViews({
|
|
13
|
+
* async list(resource) {
|
|
14
|
+
* return (await View.query().where("resource", resource).get()).map(toView);
|
|
15
|
+
* },
|
|
16
|
+
* async save(view) { await View.create({ ...view, userId: Auth.user()!.id }); },
|
|
17
|
+
* async remove(id) { await View.destroy(id); },
|
|
18
|
+
* });
|
|
19
|
+
*
|
|
20
|
+
* With no provider configured the Views control simply doesn't appear.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** A stored view: a name, the resource it belongs to, and the query it restores. */
|
|
24
|
+
export interface SavedView {
|
|
25
|
+
id: string;
|
|
26
|
+
/** Resource slug this view belongs to. */
|
|
27
|
+
resource: string;
|
|
28
|
+
name: string;
|
|
29
|
+
/** The list's query string, without the leading `?`. */
|
|
30
|
+
query: string;
|
|
31
|
+
/** Show this view to everyone rather than only its author. */
|
|
32
|
+
shared?: boolean;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** What the app supplies so views can be listed, saved and deleted. */
|
|
36
|
+
export interface SavedViewProvider {
|
|
37
|
+
/** Views for a resource, in the order they should appear. */
|
|
38
|
+
list(resource: string): Promise<SavedView[]> | SavedView[];
|
|
39
|
+
/** Persist a new view. The panel supplies everything but the id. */
|
|
40
|
+
save(view: Omit<SavedView, "id">): Promise<void> | void;
|
|
41
|
+
/** Delete one by id. */
|
|
42
|
+
remove(id: string): Promise<void> | void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* The query-string keys a saved view carries.
|
|
47
|
+
*
|
|
48
|
+
* Deliberately explicit rather than "everything in the URL": a saved view should
|
|
49
|
+
* restore how the list was *shaped*, not which page of it happened to be open,
|
|
50
|
+
* so `page` is excluded and a restored view always starts at the top.
|
|
51
|
+
*/
|
|
52
|
+
export const VIEW_PARAMS = [
|
|
53
|
+
"search",
|
|
54
|
+
"filters",
|
|
55
|
+
"tab",
|
|
56
|
+
"sortBy",
|
|
57
|
+
"sortDir",
|
|
58
|
+
"sort",
|
|
59
|
+
"trashed",
|
|
60
|
+
"perPage",
|
|
61
|
+
"cols",
|
|
62
|
+
"group",
|
|
63
|
+
] as const;
|
|
64
|
+
|
|
65
|
+
/** Reduce a full query string to just the parts a view restores. */
|
|
66
|
+
export function viewQuery(params: URLSearchParams | string): string {
|
|
67
|
+
const source = typeof params === "string" ? new URLSearchParams(params) : params;
|
|
68
|
+
const out = new URLSearchParams();
|
|
69
|
+
for (const key of VIEW_PARAMS) {
|
|
70
|
+
const value = source.get(key);
|
|
71
|
+
if (value != null && value !== "") out.set(key, value);
|
|
72
|
+
}
|
|
73
|
+
return out.toString();
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Whether a view matches the list's current state — used to mark the active one. */
|
|
77
|
+
export function viewIsActive(view: SavedView, current: URLSearchParams | string): boolean {
|
|
78
|
+
return viewQuery(view.query) === viewQuery(current);
|
|
79
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ability resolution for panel destinations.
|
|
3
|
+
*
|
|
4
|
+
* Resources, pages, widgets and nav entries each name an ability; the panel
|
|
5
|
+
* consults it twice — once to decide whether to *draw* the entry, and again in
|
|
6
|
+
* the route guard to decide whether to *serve* it. Drawing and serving share
|
|
7
|
+
* this one resolver so a hidden destination is also an unreachable one.
|
|
8
|
+
*
|
|
9
|
+
* Resolution order, first match wins:
|
|
10
|
+
*
|
|
11
|
+
* 1. `authorize` in `config/admin.ts` — the app's own hook, for panels that
|
|
12
|
+
* model permissions themselves.
|
|
13
|
+
* 2. The `gate` binding (`@zerotal/auth`'s `GateService`), resolved from the
|
|
14
|
+
* container by name so the auth package stays an optional dependency.
|
|
15
|
+
* 3. Neither configured — allow only where dev surfaces are allowed. A panel
|
|
16
|
+
* with no authorization wired is closed in production-like environments,
|
|
17
|
+
* matching {@link AdminGuardMiddleware}'s posture.
|
|
18
|
+
*
|
|
19
|
+
* Rule 3 is what makes contributed pages safe to auto-register: a package can
|
|
20
|
+
* put a page in the sidebar, but it cannot put one in front of a production user
|
|
21
|
+
* who has no authorization configured.
|
|
22
|
+
*/
|
|
23
|
+
import { tryCurrentApp, isDevSurfaceAllowed } from "@zerotal/core";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The slice of `@zerotal/auth`'s `GateService` the panel uses. Declared locally
|
|
27
|
+
* and resolved by binding name, so admin never imports the auth package.
|
|
28
|
+
*
|
|
29
|
+
* The async form is deliberate: abilities that hit the database return a promise,
|
|
30
|
+
* and the sync `allows()` treats a promise as truthy — which would wrongly allow.
|
|
31
|
+
*/
|
|
32
|
+
interface GateLike {
|
|
33
|
+
allowsAsync(ability: string, model?: object): Promise<boolean>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The app's own authorization hook, supplied as `authorize` in `config/admin.ts`. */
|
|
37
|
+
export type AdminAuthorizer = (ability: string) => boolean | Promise<boolean>;
|
|
38
|
+
|
|
39
|
+
/** Resolve the `gate` binding, or `undefined` when `@zerotal/auth` isn't installed. */
|
|
40
|
+
function gate(): GateLike | undefined {
|
|
41
|
+
const app = tryCurrentApp();
|
|
42
|
+
if (!app) return undefined;
|
|
43
|
+
// `gate` is only a known binding when auth's module augmentation is loaded, which
|
|
44
|
+
// admin does not import — resolve by name and re-type against the local shape.
|
|
45
|
+
return app.container.tryMake("gate" as never) as GateLike | undefined;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Whether the current user holds `ability`.
|
|
50
|
+
*
|
|
51
|
+
* An `undefined` ability means the destination declares no requirement of its own
|
|
52
|
+
* and is governed solely by the panel guard — app-authored pages may do this;
|
|
53
|
+
* package contributions may not (see {@link PageContribution}).
|
|
54
|
+
*
|
|
55
|
+
* Never throws: any error resolving the ability denies, so a broken policy closes
|
|
56
|
+
* the door rather than opening it.
|
|
57
|
+
*/
|
|
58
|
+
export async function resolveAbility(
|
|
59
|
+
ability: string | undefined,
|
|
60
|
+
authorizer: AdminAuthorizer | undefined,
|
|
61
|
+
): Promise<boolean> {
|
|
62
|
+
if (ability === undefined) return true;
|
|
63
|
+
|
|
64
|
+
try {
|
|
65
|
+
if (authorizer) return await authorizer(ability);
|
|
66
|
+
const g = gate();
|
|
67
|
+
if (g) return await g.allowsAsync(ability);
|
|
68
|
+
} catch {
|
|
69
|
+
return false;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return isDevSurfaceAllowed(Bun.env["APP_ENV"] ?? "");
|
|
73
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { ZerotalError } from "@zerotal/core";
|
|
2
|
+
import type { Action } from "../actions/Action.ts";
|
|
3
|
+
import type { ActionContext } from "../actions/Action.ts";
|
|
4
|
+
import type { AdminRecord } from "../Resource.ts";
|
|
5
|
+
import type { ResourceClass } from "../Panel.ts";
|
|
6
|
+
import type { RelationManager } from "../relations/RelationManager.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Server-side authorization for admin RPCs.
|
|
10
|
+
*
|
|
11
|
+
* The admin panel exposes its mutations as Flow `@expose` methods, which are dispatched
|
|
12
|
+
* straight from a client frame: `FlowProvider` resolves the method by name and applies the
|
|
13
|
+
* client-supplied arguments. Authorization used to be consulted in exactly one place —
|
|
14
|
+
* `Action.isVisibleFor()`, called while *rendering* the row-action column — so hiding a button
|
|
15
|
+
* was the entire control. Any user who could load an admin page could call `runAction`,
|
|
16
|
+
* `runBulkAction`, `deleteRecord` or `deleteRelated` directly with arguments of their choosing.
|
|
17
|
+
*
|
|
18
|
+
* The helpers here are the enforcement point. They are deliberately small, deliberately
|
|
19
|
+
* fail-closed, and deliberately called at the *top* of every exposed handler, before any
|
|
20
|
+
* argument is used to look something up.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Raised when an admin RPC is refused. Carries a 403 so the framework's exception handler
|
|
25
|
+
* renders it correctly if it escapes a Flow action.
|
|
26
|
+
*/
|
|
27
|
+
export class AdminForbiddenError extends ZerotalError {
|
|
28
|
+
constructor(what: string) {
|
|
29
|
+
super(`Not authorized: ${what}.`, "E_ADMIN_FORBIDDEN", 403);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Assert the current user may perform `ability` on `record` for this resource.
|
|
35
|
+
*
|
|
36
|
+
* Delegates to `Resource.can()`, which apps override to hit their Gate/policy layer. The
|
|
37
|
+
* built-in default returns `true`, so this is only as strong as the app's implementation —
|
|
38
|
+
* but it is now actually *called*, which it previously was not on any mutating path.
|
|
39
|
+
*/
|
|
40
|
+
export function assertCan(
|
|
41
|
+
resource: ResourceClass,
|
|
42
|
+
ability: string,
|
|
43
|
+
record?: AdminRecord | Record<string, unknown>,
|
|
44
|
+
): void {
|
|
45
|
+
if (!resource.can(ability, record as AdminRecord | undefined)) {
|
|
46
|
+
throw new AdminForbiddenError(`${ability} on ${resource.getLabel()}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Assert an action may run for this record, applying the same `visible` + `authorize`
|
|
52
|
+
* predicates the renderer uses to decide whether to draw the button.
|
|
53
|
+
*
|
|
54
|
+
* Passing the action through the same gate as the UI is the point: a button the user cannot
|
|
55
|
+
* see is now a call the user cannot make.
|
|
56
|
+
*/
|
|
57
|
+
export function assertActionAllowed(
|
|
58
|
+
action: Action,
|
|
59
|
+
record: AdminRecord | Record<string, unknown> | undefined,
|
|
60
|
+
ctx: ActionContext,
|
|
61
|
+
): void {
|
|
62
|
+
if (!action.isVisibleFor(record as AdminRecord | undefined, ctx)) {
|
|
63
|
+
throw new AdminForbiddenError(`action "${action._key}"`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a relation manager by the slug of the resource it points at, restricted to the
|
|
69
|
+
* relations this resource actually declares.
|
|
70
|
+
*
|
|
71
|
+
* `deleteRelated(slug, id)` used to call `Panel.find(String(slug))`, which resolves *any*
|
|
72
|
+
* registered resource — so from a page like `/admin/posts/1` a crafted frame could invoke
|
|
73
|
+
* `deleteRelated("users", 1)` and destroy an unrelated record. Resolving from
|
|
74
|
+
* `resource.relations()` instead means the slug can only name something this page genuinely
|
|
75
|
+
* manages.
|
|
76
|
+
*
|
|
77
|
+
* @returns the matching relation manager, or `null` when the slug is not a declared relation.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveDeclaredRelation(
|
|
80
|
+
resource: ResourceClass,
|
|
81
|
+
slug: string,
|
|
82
|
+
): RelationManager | null {
|
|
83
|
+
return (
|
|
84
|
+
resource.relations().find((rel: RelationManager) => rel._resource.getSlug() === slug) ?? null
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Resolve a relation manager by the parent model's relationship *method* name, restricted to
|
|
90
|
+
* the relations this resource declares as BelongsToMany.
|
|
91
|
+
*
|
|
92
|
+
* `attachRelated`/`detachRelated` took a raw method name and invoked it on the parent model,
|
|
93
|
+
* so any zero-argument method reachable on the model could be called. Only names a relation
|
|
94
|
+
* manager declares via `_relationName` are accepted now.
|
|
95
|
+
*/
|
|
96
|
+
export function resolveDeclaredRelationByName(
|
|
97
|
+
resource: ResourceClass,
|
|
98
|
+
relationName: string,
|
|
99
|
+
): RelationManager | null {
|
|
100
|
+
return (
|
|
101
|
+
resource
|
|
102
|
+
.relations()
|
|
103
|
+
.find((rel: RelationManager) => rel._relationName === relationName && rel._canAttach) ?? null
|
|
104
|
+
);
|
|
105
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tab-count caching. The list page's filter-tab badges (`COUNT(*) … WHERE …`)
|
|
3
|
+
* are read on every list view but only change when a record is created /
|
|
4
|
+
* updated / deleted. We cache the per-resource count map and let the
|
|
5
|
+
* {@link AdminProvider} invalidate it from the ORM's `ModelChanged` event, so
|
|
6
|
+
* counts stay correct no matter where the write came from (admin or otherwise).
|
|
7
|
+
*
|
|
8
|
+
* Cache is best-effort: if no cache driver is bound (e.g. isolated tests), the
|
|
9
|
+
* counts are simply computed each time.
|
|
10
|
+
*/
|
|
11
|
+
import { Cache } from "@zerotal/cache";
|
|
12
|
+
|
|
13
|
+
const PREFIX = "zerotal:admin:counts:";
|
|
14
|
+
/** Safety-net TTL (1h) in case an invalidation is ever missed. */
|
|
15
|
+
const TTL = 3600;
|
|
16
|
+
|
|
17
|
+
/** Return the cached tab-count map for a slug, computing + caching on a miss. */
|
|
18
|
+
export async function rememberTabCounts(
|
|
19
|
+
slug: string,
|
|
20
|
+
compute: () => Promise<Record<string, number>>,
|
|
21
|
+
): Promise<Record<string, number>> {
|
|
22
|
+
try {
|
|
23
|
+
return await Cache.remember(`${PREFIX}${slug}`, TTL, compute);
|
|
24
|
+
} catch {
|
|
25
|
+
// No cache bound — fall back to a direct computation.
|
|
26
|
+
return compute();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Drop the cached counts for a slug (called when its records change). */
|
|
31
|
+
export async function forgetTabCounts(slug: string): Promise<void> {
|
|
32
|
+
try {
|
|
33
|
+
await Cache.forget(`${PREFIX}${slug}`);
|
|
34
|
+
} catch {
|
|
35
|
+
/* best-effort */
|
|
36
|
+
}
|
|
37
|
+
}
|