tempest-express-sdk 0.24.0 → 0.26.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/README.md +1 -1
- package/dist/{chunk-6KJTKSG5.js → chunk-GLZYNX63.js} +3 -3
- package/dist/{chunk-6KJTKSG5.js.map → chunk-GLZYNX63.js.map} +1 -1
- package/dist/cli.cjs +1 -1
- package/dist/cli.cjs.map +1 -1
- package/dist/cli.js +1 -1
- package/dist/index.cjs +777 -109
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +625 -178
- package/dist/index.d.ts +625 -178
- package/dist/index.js +769 -111
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -9444,6 +9444,60 @@ var MessagingHub = class {
|
|
|
9444
9444
|
}
|
|
9445
9445
|
};
|
|
9446
9446
|
|
|
9447
|
+
// src/admin/dashboard.ts
|
|
9448
|
+
function metricCard(label, compute, helpText) {
|
|
9449
|
+
return helpText === void 0 ? { label, compute } : { label, compute, helpText };
|
|
9450
|
+
}
|
|
9451
|
+
function trendPercent(trend) {
|
|
9452
|
+
if (trend.previous === 0) return null;
|
|
9453
|
+
return (trend.value - trend.previous) / trend.previous * 100;
|
|
9454
|
+
}
|
|
9455
|
+
function trendDirection(trend) {
|
|
9456
|
+
if (trend.value > trend.previous) return "up";
|
|
9457
|
+
if (trend.value < trend.previous) return "down";
|
|
9458
|
+
return "flat";
|
|
9459
|
+
}
|
|
9460
|
+
function partitionTotal(partition) {
|
|
9461
|
+
return partition.segments.reduce((total, segment) => total + segment.value, 0);
|
|
9462
|
+
}
|
|
9463
|
+
|
|
9464
|
+
// src/admin/lenses.ts
|
|
9465
|
+
function slugify(name) {
|
|
9466
|
+
return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
|
|
9467
|
+
}
|
|
9468
|
+
function adminLens(options) {
|
|
9469
|
+
return {
|
|
9470
|
+
name: options.name,
|
|
9471
|
+
slug: slugify(options.name),
|
|
9472
|
+
label: options.label ?? options.name,
|
|
9473
|
+
filters: options.filters ?? {},
|
|
9474
|
+
orderBy: options.orderBy ?? null
|
|
9475
|
+
};
|
|
9476
|
+
}
|
|
9477
|
+
|
|
9478
|
+
// src/admin/permissions.ts
|
|
9479
|
+
var AdminPermission = {
|
|
9480
|
+
VIEW: "view",
|
|
9481
|
+
CREATE: "create",
|
|
9482
|
+
EDIT: "edit",
|
|
9483
|
+
DELETE: "delete"
|
|
9484
|
+
};
|
|
9485
|
+
|
|
9486
|
+
// src/admin/actions.ts
|
|
9487
|
+
function slugify2(label) {
|
|
9488
|
+
return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
|
|
9489
|
+
}
|
|
9490
|
+
function adminAction(options, handler) {
|
|
9491
|
+
const name = options.name ?? slugify2(options.label);
|
|
9492
|
+
if (name === "") throw new Error("adminAction requires a non-empty name or label");
|
|
9493
|
+
return {
|
|
9494
|
+
name,
|
|
9495
|
+
label: options.label,
|
|
9496
|
+
handler,
|
|
9497
|
+
dangerous: options.dangerous ?? false
|
|
9498
|
+
};
|
|
9499
|
+
}
|
|
9500
|
+
|
|
9447
9501
|
// src/admin/columns.ts
|
|
9448
9502
|
function humanizeField(name) {
|
|
9449
9503
|
return name.replace(/[_-]+/g, " ").replace(/([a-z0-9])([A-Z])/g, "$1 $2").trim().split(/\s+/).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
|
|
@@ -9524,6 +9578,9 @@ function filterForColumn(column6) {
|
|
|
9524
9578
|
}
|
|
9525
9579
|
return { kind: "text", options: [] };
|
|
9526
9580
|
}
|
|
9581
|
+
function foreignKeyTable(column6) {
|
|
9582
|
+
return column6.reference?.table ?? null;
|
|
9583
|
+
}
|
|
9527
9584
|
function isSearchableColumn(column6) {
|
|
9528
9585
|
const { kind } = column6.type;
|
|
9529
9586
|
return kind === "varchar" || kind === "text" || kind === "char";
|
|
@@ -9537,6 +9594,12 @@ var NEVER_EDITABLE = [
|
|
|
9537
9594
|
"hashedPassword"
|
|
9538
9595
|
];
|
|
9539
9596
|
var NEVER_LISTED = ["hashedPassword"];
|
|
9597
|
+
var AUDIT_FIELDS = [
|
|
9598
|
+
"createdAt",
|
|
9599
|
+
"updatedAt",
|
|
9600
|
+
"createdBy",
|
|
9601
|
+
"updatedBy"
|
|
9602
|
+
];
|
|
9540
9603
|
var AdminModel = class {
|
|
9541
9604
|
/** The managed model class. */
|
|
9542
9605
|
model;
|
|
@@ -9560,6 +9623,11 @@ var AdminModel = class {
|
|
|
9560
9623
|
canEdit;
|
|
9561
9624
|
/** Whether the delete action is exposed. */
|
|
9562
9625
|
canDelete;
|
|
9626
|
+
/** Audit-log model backing the detail timeline, or `null`. */
|
|
9627
|
+
auditModel;
|
|
9628
|
+
/** Saved list-view presets, in declaration order. */
|
|
9629
|
+
lenses;
|
|
9630
|
+
actions = /* @__PURE__ */ new Map();
|
|
9563
9631
|
slugOverride;
|
|
9564
9632
|
listDisplayOverride;
|
|
9565
9633
|
verboseNameOverride;
|
|
@@ -9584,6 +9652,16 @@ var AdminModel = class {
|
|
|
9584
9652
|
this.canCreate = options.canCreate ?? true;
|
|
9585
9653
|
this.canEdit = options.canEdit ?? true;
|
|
9586
9654
|
this.canDelete = options.canDelete ?? true;
|
|
9655
|
+
this.auditModel = options.auditModel ?? null;
|
|
9656
|
+
this.lenses = [...options.lenses ?? []];
|
|
9657
|
+
for (const action of options.actions ?? []) {
|
|
9658
|
+
if (this.actions.has(action.name)) {
|
|
9659
|
+
throw new Error(
|
|
9660
|
+
`Duplicate admin action name "${action.name}" on ${this.model.tablename}`
|
|
9661
|
+
);
|
|
9662
|
+
}
|
|
9663
|
+
this.actions.set(action.name, action);
|
|
9664
|
+
}
|
|
9587
9665
|
const known = new Set(this.columnNames());
|
|
9588
9666
|
for (const [option, names] of [
|
|
9589
9667
|
["listDisplay", this.listDisplayOverride ?? []],
|
|
@@ -9660,6 +9738,25 @@ var AdminModel = class {
|
|
|
9660
9738
|
if (this.listDisplayOverride !== null) return [...this.listDisplayOverride];
|
|
9661
9739
|
return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
|
|
9662
9740
|
}
|
|
9741
|
+
/**
|
|
9742
|
+
* Look a lens up by its slug.
|
|
9743
|
+
*
|
|
9744
|
+
* @param slug - The `?lens=` value.
|
|
9745
|
+
* @returns The lens, or `null` when nothing matches.
|
|
9746
|
+
*/
|
|
9747
|
+
getLens(slug) {
|
|
9748
|
+
return this.lenses.find((lens) => lens.slug === slug) ?? null;
|
|
9749
|
+
}
|
|
9750
|
+
/**
|
|
9751
|
+
* Return the audit/timestamp columns the model actually declares.
|
|
9752
|
+
*
|
|
9753
|
+
* @returns The subset of `createdAt` / `updatedAt` / `createdBy` /
|
|
9754
|
+
* `updatedBy` present on the model, in that order.
|
|
9755
|
+
*/
|
|
9756
|
+
auditFieldNames() {
|
|
9757
|
+
const known = new Set(this.columnNames());
|
|
9758
|
+
return AUDIT_FIELDS.filter((name) => known.has(name));
|
|
9759
|
+
}
|
|
9663
9760
|
/**
|
|
9664
9761
|
* Return the columns the detail view renders.
|
|
9665
9762
|
*
|
|
@@ -9668,10 +9765,16 @@ var AdminModel = class {
|
|
|
9668
9765
|
* where an operator goes to see the whole record, so trimming it there would
|
|
9669
9766
|
* hide data with nowhere else to read it.
|
|
9670
9767
|
*
|
|
9671
|
-
*
|
|
9768
|
+
* The audit/timestamp columns are held back too — they render in the detail
|
|
9769
|
+
* view's own audit panel, next to the change history, rather than scattered
|
|
9770
|
+
* among the domain fields.
|
|
9771
|
+
*
|
|
9772
|
+
* @returns Every domain column, in declaration order.
|
|
9672
9773
|
*/
|
|
9673
9774
|
detailFieldNames() {
|
|
9674
|
-
return this.columnNames().filter(
|
|
9775
|
+
return this.columnNames().filter(
|
|
9776
|
+
(name) => !NEVER_LISTED.includes(name) && !AUDIT_FIELDS.includes(name)
|
|
9777
|
+
);
|
|
9675
9778
|
}
|
|
9676
9779
|
/**
|
|
9677
9780
|
* Return the columns a create/edit form exposes.
|
|
@@ -9686,6 +9789,25 @@ var AdminModel = class {
|
|
|
9686
9789
|
const skip = /* @__PURE__ */ new Set([...this.readonlyFields, ...NEVER_EDITABLE]);
|
|
9687
9790
|
return this.columnNames().filter((name) => !skip.has(name));
|
|
9688
9791
|
}
|
|
9792
|
+
/**
|
|
9793
|
+
* Return the registered custom actions, in declaration order.
|
|
9794
|
+
*
|
|
9795
|
+
* @returns The actions passed via `actions` (empty when none). The model
|
|
9796
|
+
* type is erased here, the way {@link AdminSite} erases it when it stores a
|
|
9797
|
+
* configuration — a registry keyed by slug cannot stay generic.
|
|
9798
|
+
*/
|
|
9799
|
+
customActions() {
|
|
9800
|
+
return [...this.actions.values()];
|
|
9801
|
+
}
|
|
9802
|
+
/**
|
|
9803
|
+
* Look a custom action up by name.
|
|
9804
|
+
*
|
|
9805
|
+
* @param name - The action identifier (its submitted form value).
|
|
9806
|
+
* @returns The action, or `null` when nothing matches.
|
|
9807
|
+
*/
|
|
9808
|
+
getAction(name) {
|
|
9809
|
+
return this.actions.get(name) ?? null;
|
|
9810
|
+
}
|
|
9689
9811
|
/**
|
|
9690
9812
|
* Build a repository for this model bound to a session.
|
|
9691
9813
|
*
|
|
@@ -9727,10 +9849,12 @@ function buildFormFields(admin, options = {}) {
|
|
|
9727
9849
|
const columns = adminColumns(admin.model);
|
|
9728
9850
|
const values = options.values ?? {};
|
|
9729
9851
|
const errors = options.errors ?? {};
|
|
9852
|
+
const foreignKeys = options.foreignKeyOptions ?? {};
|
|
9730
9853
|
return admin.editableFieldNames().flatMap((name) => {
|
|
9731
9854
|
const column6 = columns[name];
|
|
9732
9855
|
if (column6 === void 0) return [];
|
|
9733
|
-
const
|
|
9856
|
+
const related = foreignKeys[name];
|
|
9857
|
+
const spec = related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
|
|
9734
9858
|
const raw = name in values ? values[name] : literalDefault(column6);
|
|
9735
9859
|
return [
|
|
9736
9860
|
{
|
|
@@ -9780,7 +9904,7 @@ function coerceValue(column6, widget, raw) {
|
|
|
9780
9904
|
throw new Error("Enter valid JSON.");
|
|
9781
9905
|
}
|
|
9782
9906
|
case "select": {
|
|
9783
|
-
const allowed = meta.values ?? [];
|
|
9907
|
+
const allowed = kind === "enum" ? meta.values ?? [] : [];
|
|
9784
9908
|
if (allowed.length > 0 && !allowed.includes(raw)) {
|
|
9785
9909
|
throw new Error(`Choose one of: ${allowed.join(", ")}.`);
|
|
9786
9910
|
}
|
|
@@ -9828,6 +9952,28 @@ function formatCellValue(value) {
|
|
|
9828
9952
|
if (typeof value === "object") return JSON.stringify(value);
|
|
9829
9953
|
return String(value);
|
|
9830
9954
|
}
|
|
9955
|
+
function foreignKeyFields(admin) {
|
|
9956
|
+
const columns = adminColumns(admin.model);
|
|
9957
|
+
const out = {};
|
|
9958
|
+
for (const name of admin.editableFieldNames()) {
|
|
9959
|
+
const column6 = columns[name];
|
|
9960
|
+
if (column6 === void 0) continue;
|
|
9961
|
+
const table = foreignKeyTable(column6);
|
|
9962
|
+
if (table !== null) out[name] = table;
|
|
9963
|
+
}
|
|
9964
|
+
return out;
|
|
9965
|
+
}
|
|
9966
|
+
function foreignKeyLabel(admin, row) {
|
|
9967
|
+
for (const field of admin.searchFields) {
|
|
9968
|
+
const value = row[field];
|
|
9969
|
+
if (typeof value === "string" && value !== "") return value;
|
|
9970
|
+
}
|
|
9971
|
+
for (const field of ["name", "title", "email", "label", "reference"]) {
|
|
9972
|
+
const value = row[field];
|
|
9973
|
+
if (typeof value === "string" && value !== "") return value;
|
|
9974
|
+
}
|
|
9975
|
+
return String(row[admin.identityField] ?? "");
|
|
9976
|
+
}
|
|
9831
9977
|
|
|
9832
9978
|
// src/admin/auth.ts
|
|
9833
9979
|
var UserModelAuthBackend = class {
|
|
@@ -10085,6 +10231,8 @@ var AdminSite = class {
|
|
|
10085
10231
|
siteUrl;
|
|
10086
10232
|
/** Typed appearance overrides. */
|
|
10087
10233
|
theme;
|
|
10234
|
+
/** Business-metric cards rendered at the top of the dashboard. */
|
|
10235
|
+
dashboardCards;
|
|
10088
10236
|
registry = /* @__PURE__ */ new Map();
|
|
10089
10237
|
/**
|
|
10090
10238
|
* Initialize the site.
|
|
@@ -10097,6 +10245,7 @@ var AdminSite = class {
|
|
|
10097
10245
|
this.indexSubtitle = options.indexSubtitle ?? "Site administration";
|
|
10098
10246
|
this.siteUrl = options.siteUrl ?? null;
|
|
10099
10247
|
this.theme = options.theme ?? {};
|
|
10248
|
+
this.dashboardCards = [...options.dashboardCards ?? []];
|
|
10100
10249
|
}
|
|
10101
10250
|
/**
|
|
10102
10251
|
* Return the centered header brand text.
|
|
@@ -11916,7 +12065,7 @@ function renderMfaPage(context, error) {
|
|
|
11916
12065
|
</section>`;
|
|
11917
12066
|
return renderLayout(context, `Two-factor \xB7 ${context.site.title}`, body);
|
|
11918
12067
|
}
|
|
11919
|
-
function renderDashboardPage(context, cards, metrics) {
|
|
12068
|
+
function renderDashboardPage(context, cards, metrics, businessCards = []) {
|
|
11920
12069
|
const metricsPanel = metrics === null ? "" : `<div class="tempest-admin-stats" aria-label="System metrics">
|
|
11921
12070
|
<div class="tempest-admin-stat">
|
|
11922
12071
|
<span class="tempest-admin-stat__label">CPU</span>
|
|
@@ -11928,6 +12077,7 @@ function renderDashboardPage(context, cards, metrics) {
|
|
|
11928
12077
|
<span class="tempest-admin-stat__sub">${escapeHtml(metrics.memoryUsedGb)} / ${escapeHtml(metrics.memoryTotalGb)} GB</span>
|
|
11929
12078
|
</div>
|
|
11930
12079
|
</div>`;
|
|
12080
|
+
const business = businessCards.length > 0 ? `<div class="tempest-admin-cards" aria-label="Business metrics">${businessCards.map(renderBusinessCard).join("")}</div>` : "";
|
|
11931
12081
|
const models = cards.length > 0 ? `<div class="tempest-admin-models">${cards.map(
|
|
11932
12082
|
(card) => `<article class="tempest-admin-model-card">
|
|
11933
12083
|
<header class="tempest-admin-model-card__head">
|
|
@@ -11944,10 +12094,53 @@ function renderDashboardPage(context, cards, metrics) {
|
|
|
11944
12094
|
<h1>${escapeHtml(context.site.title)}</h1>
|
|
11945
12095
|
<p>${escapeHtml(context.site.indexSubtitle)}</p>
|
|
11946
12096
|
${metricsPanel}
|
|
12097
|
+
${business}
|
|
11947
12098
|
${models}
|
|
11948
12099
|
</section>`;
|
|
11949
12100
|
return renderLayout(context, context.site.title, body);
|
|
11950
12101
|
}
|
|
12102
|
+
function renderBusinessCard(card) {
|
|
12103
|
+
const help = card.helpText !== null ? `<span class="tempest-admin-card__help">${escapeHtml(card.helpText)}</span>` : "";
|
|
12104
|
+
if (card.error !== null) {
|
|
12105
|
+
return `<article class="tempest-admin-card tempest-admin-card--value">
|
|
12106
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12107
|
+
<span class="tempest-admin-card__value">\u2014</span>
|
|
12108
|
+
<span class="tempest-admin-card__help">${escapeHtml(card.error)}</span>
|
|
12109
|
+
</article>`;
|
|
12110
|
+
}
|
|
12111
|
+
const unit = card.unit !== null ? ` <small>${escapeHtml(card.unit)}</small>` : "";
|
|
12112
|
+
if (card.kind === "partition") {
|
|
12113
|
+
const parts = card.segments.map(
|
|
12114
|
+
(segment) => `<li>
|
|
12115
|
+
<span class="tempest-admin-card__part-label">${escapeHtml(segment.label)}</span>
|
|
12116
|
+
<span class="tempest-admin-card__part-bar"><span style="width: ${Math.round(segment.percent)}%"></span></span>
|
|
12117
|
+
<span class="tempest-admin-card__part-value">${escapeHtml(segment.value)}</span>
|
|
12118
|
+
</li>`
|
|
12119
|
+
).join("");
|
|
12120
|
+
return `<article class="tempest-admin-card tempest-admin-card--partition">
|
|
12121
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12122
|
+
<ul class="tempest-admin-card__parts">${parts}</ul>
|
|
12123
|
+
${help}
|
|
12124
|
+
</article>`;
|
|
12125
|
+
}
|
|
12126
|
+
if (card.kind === "trend") {
|
|
12127
|
+
const arrow = card.direction === "up" ? "\u25B2" : card.direction === "down" ? "\u25BC" : "\u25AC";
|
|
12128
|
+
return `<article class="tempest-admin-card tempest-admin-card--trend">
|
|
12129
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12130
|
+
<span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
|
|
12131
|
+
<span class="tempest-admin-card__trend tempest-admin-card__trend--${escapeHtml(card.direction)}">
|
|
12132
|
+
${arrow} ${card.percent === null ? "\u2014" : escapeHtml(card.percent)}
|
|
12133
|
+
<small>vs prev ${escapeHtml(card.previous)}</small>
|
|
12134
|
+
</span>
|
|
12135
|
+
${help}
|
|
12136
|
+
</article>`;
|
|
12137
|
+
}
|
|
12138
|
+
return `<article class="tempest-admin-card tempest-admin-card--value">
|
|
12139
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12140
|
+
<span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
|
|
12141
|
+
${help}
|
|
12142
|
+
</article>`;
|
|
12143
|
+
}
|
|
11951
12144
|
function renderFilter(filter) {
|
|
11952
12145
|
const name = `filter_${filter.field}`;
|
|
11953
12146
|
if (filter.kind === "select") {
|
|
@@ -11965,15 +12158,40 @@ function renderFilter(filter) {
|
|
|
11965
12158
|
return `<label><span>${escapeHtml(filter.label)}</span><input type="text" name="${escapeHtml(name)}" value="${escapeHtml(filter.value)}"></label>`;
|
|
11966
12159
|
}
|
|
11967
12160
|
function renderListPage(context, view) {
|
|
12161
|
+
const bulk = view.bulkActions.length > 0 && context.session !== null;
|
|
12162
|
+
const checkColumn = bulk ? 1 : 0;
|
|
11968
12163
|
const headers = view.columns.map((column6) => {
|
|
11969
12164
|
const state = view.sort[column6];
|
|
11970
12165
|
if (state === void 0) return `<th>${escapeHtml(column6)}</th>`;
|
|
11971
12166
|
const arrow = state.active ? state.ascending ? "\u25B2" : "\u25BC" : "\u2195";
|
|
11972
12167
|
return `<th><a class="tempest-sort${state.active ? " tempest-sort--active" : ""}" href="${escapeHtml(state.url)}"><span>${escapeHtml(column6)}</span><span class="tempest-sort__arrow" aria-hidden="true">${arrow}</span></a></th>`;
|
|
11973
12168
|
}).join("");
|
|
11974
|
-
const rows = view.rows.length > 0 ? view.rows.map(
|
|
11975
|
-
|
|
11976
|
-
|
|
12169
|
+
const rows = view.rows.length > 0 ? view.rows.map((row) => {
|
|
12170
|
+
const check = bulk ? `<td class="tempest-admin-list__check"><input type="checkbox" name="ids" value="${escapeHtml(row.identity)}" data-row-check aria-label="Select row"></td>` : "";
|
|
12171
|
+
const cells = row.cells.map((cell) => `<td>${escapeHtml(cell)}</td>`).join("");
|
|
12172
|
+
return `<tr>${check}${cells}<td><a href="${escapeHtml(row.url)}">View</a></td></tr>`;
|
|
12173
|
+
}).join("") : `<tr><td colspan="${view.columns.length + 1 + checkColumn}">No records.</td></tr>`;
|
|
12174
|
+
const bulkBar = bulk ? `<form method="post" action="${escapeHtml(view.bulkUrl)}" class="tempest-admin-bulk" onsubmit="return confirm('Apply the selected action to the checked rows?');">
|
|
12175
|
+
<input type="hidden" name="csrf_token" value="${escapeHtml(context.session?.csrfToken)}">
|
|
12176
|
+
<div class="tempest-admin-bulk__bar">
|
|
12177
|
+
<select name="action" aria-label="Bulk action">
|
|
12178
|
+
${view.bulkActions.map(
|
|
12179
|
+
(action) => `<option value="${escapeHtml(action.value)}">${escapeHtml(action.label)}${action.dangerous ? " \u26A0" : ""}</option>`
|
|
12180
|
+
).join("")}
|
|
12181
|
+
</select>
|
|
12182
|
+
<button type="submit">Apply to selected</button>
|
|
12183
|
+
</div>` : "";
|
|
12184
|
+
const selectAllScript = bulk ? `<script>
|
|
12185
|
+
(function () {
|
|
12186
|
+
var master = document.querySelector('[data-select-all]');
|
|
12187
|
+
if (!master) return;
|
|
12188
|
+
master.addEventListener('change', function () {
|
|
12189
|
+
document.querySelectorAll('[data-row-check]').forEach(function (box) {
|
|
12190
|
+
box.checked = master.checked;
|
|
12191
|
+
});
|
|
12192
|
+
});
|
|
12193
|
+
})();
|
|
12194
|
+
</script>` : "";
|
|
11977
12195
|
const hasControls = view.searchable || view.filters.length > 0;
|
|
11978
12196
|
const body = `<section class="tempest-admin-list">
|
|
11979
12197
|
<header class="tempest-admin-list__header">
|
|
@@ -11988,14 +12206,22 @@ function renderListPage(context, view) {
|
|
|
11988
12206
|
</form>
|
|
11989
12207
|
<div class="tempest-admin-list__actions">
|
|
11990
12208
|
${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
|
|
12209
|
+
<a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
|
|
12210
|
+
<a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
|
|
11991
12211
|
</div>
|
|
11992
12212
|
</div>
|
|
12213
|
+
${view.lenses.length > 0 ? `<nav class="tempest-admin-lenses" aria-label="Lenses">${view.lenses.map(
|
|
12214
|
+
(lens) => `<a class="tempest-admin-lens${lens.active ? " tempest-admin-lens--active" : ""}" href="${escapeHtml(lens.url)}">${escapeHtml(lens.label)}</a>`
|
|
12215
|
+
).join("")}</nav>` : ""}
|
|
12216
|
+
${bulkBar}
|
|
11993
12217
|
<div class="tempest-admin-table-wrap">
|
|
11994
12218
|
<table class="tempest-admin-list__table">
|
|
11995
|
-
<thead><tr>${headers}<th>Actions</th></tr></thead>
|
|
12219
|
+
<thead><tr>${bulk ? '<th class="tempest-admin-list__check"><input type="checkbox" data-select-all aria-label="Select all"></th>' : ""}${headers}<th>Actions</th></tr></thead>
|
|
11996
12220
|
<tbody>${rows}</tbody>
|
|
11997
12221
|
</table>
|
|
11998
12222
|
</div>
|
|
12223
|
+
${bulk ? "</form>" : ""}
|
|
12224
|
+
${selectAllScript}
|
|
11999
12225
|
${view.pages > 1 ? `<nav class="tempest-admin-list__pagination" aria-label="Pagination">
|
|
12000
12226
|
${view.prevUrl !== null ? `<a href="${escapeHtml(view.prevUrl)}">\u2190 Prev</a>` : ""}
|
|
12001
12227
|
<span>Page ${escapeHtml(view.page)} of ${escapeHtml(view.pages)}</span>
|
|
@@ -12010,6 +12236,7 @@ function renderDetailPage(context, view) {
|
|
|
12010
12236
|
const fields = view.fields.map(
|
|
12011
12237
|
(field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
|
|
12012
12238
|
).join("");
|
|
12239
|
+
const auditPanel = view.audit === null ? "" : renderAuditPanel(view.audit);
|
|
12013
12240
|
const body = `<section class="tempest-admin-detail">
|
|
12014
12241
|
<header class="tempest-admin-detail__header">
|
|
12015
12242
|
<h1>${escapeHtml(view.title)} \xB7 ${escapeHtml(view.identity)}</h1>
|
|
@@ -12023,9 +12250,37 @@ function renderDetailPage(context, view) {
|
|
|
12023
12250
|
</div>
|
|
12024
12251
|
</header>
|
|
12025
12252
|
<dl class="tempest-admin-detail__fields">${fields}</dl>
|
|
12253
|
+
${auditPanel}
|
|
12026
12254
|
</section>`;
|
|
12027
12255
|
return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
|
|
12028
12256
|
}
|
|
12257
|
+
function renderAuditPanel(audit) {
|
|
12258
|
+
const rows = audit.fields.map(
|
|
12259
|
+
(field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
|
|
12260
|
+
).join("");
|
|
12261
|
+
const history = audit.history.length > 0 ? `<ol class="tempest-admin-history">${audit.history.map((entry) => {
|
|
12262
|
+
const changes = entry.changes.length > 0 ? `<table class="tempest-admin-history__changes"><thead><tr><th>Field</th><th>Before</th><th>After</th></tr></thead><tbody>${entry.changes.map(
|
|
12263
|
+
(change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
|
|
12264
|
+
).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
|
|
12265
|
+
const context = entry.context !== null ? `<pre class="tempest-admin-detail__json">${escapeHtml(entry.context)}</pre>` : "";
|
|
12266
|
+
return `<li class="tempest-admin-history__item">
|
|
12267
|
+
<details>
|
|
12268
|
+
<summary>
|
|
12269
|
+
<span class="tempest-admin-history__action">${escapeHtml(entry.action)}</span>
|
|
12270
|
+
<span class="tempest-admin-history__actor">${escapeHtml(entry.actor)}</span>
|
|
12271
|
+
<span class="tempest-admin-history__at">${escapeHtml(entry.at)}</span>
|
|
12272
|
+
</summary>
|
|
12273
|
+
${changes}
|
|
12274
|
+
${context}
|
|
12275
|
+
</details>
|
|
12276
|
+
</li>`;
|
|
12277
|
+
}).join("")}</ol>` : "";
|
|
12278
|
+
return `<section class="tempest-admin-audit">
|
|
12279
|
+
<h2>Audit</h2>
|
|
12280
|
+
<dl class="tempest-admin-detail__fields">${rows}</dl>
|
|
12281
|
+
${history}
|
|
12282
|
+
</section>`;
|
|
12283
|
+
}
|
|
12029
12284
|
function renderFormField(field) {
|
|
12030
12285
|
const required = field.required ? " required" : "";
|
|
12031
12286
|
const name = escapeHtml(field.name);
|
|
@@ -12089,11 +12344,17 @@ function renderFormPage(context, view) {
|
|
|
12089
12344
|
return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
|
|
12090
12345
|
}
|
|
12091
12346
|
var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
|
|
12347
|
+
var AUDIT_HISTORY_LIMIT = 50;
|
|
12348
|
+
var FK_OPTION_CAP = 1e3;
|
|
12349
|
+
var FLASH_MAX_LENGTH = 300;
|
|
12092
12350
|
var FLASH_MESSAGES = {
|
|
12093
12351
|
created: { text: "Record created.", level: "success" },
|
|
12094
12352
|
updated: { text: "Record updated.", level: "success" },
|
|
12095
12353
|
deleted: { text: "Record deleted.", level: "success" }
|
|
12096
12354
|
};
|
|
12355
|
+
async function runCustomAction(action, context) {
|
|
12356
|
+
return await action.handler(context) ?? null;
|
|
12357
|
+
}
|
|
12097
12358
|
function queryString(value) {
|
|
12098
12359
|
if (typeof value === "string") return value.trim();
|
|
12099
12360
|
if (Array.isArray(value) && typeof value[0] === "string") return value[0].trim();
|
|
@@ -12111,6 +12372,7 @@ function makeAdminRouter(site, options) {
|
|
|
12111
12372
|
const prefix = (options.prefix ?? "/admin").replace(/\/$/, "");
|
|
12112
12373
|
const theme = resolveAdminTheme(site.theme);
|
|
12113
12374
|
const showMetrics = options.showMetrics ?? true;
|
|
12375
|
+
const exportMaxRows = options.exportMaxRows ?? 5e3;
|
|
12114
12376
|
const sessions = new AdminSessionStore({
|
|
12115
12377
|
secret: options.secretKey,
|
|
12116
12378
|
...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
|
|
@@ -12121,21 +12383,35 @@ function makeAdminRouter(site, options) {
|
|
|
12121
12383
|
const backend = options.authBackend;
|
|
12122
12384
|
const router = express3__default.default.Router();
|
|
12123
12385
|
router.use(prefix, express3__default.default.urlencoded({ extended: false }));
|
|
12124
|
-
const context = (req, session) => ({
|
|
12386
|
+
const context = (req, session, models = site.list()) => ({
|
|
12125
12387
|
site,
|
|
12126
12388
|
theme,
|
|
12127
12389
|
prefix,
|
|
12128
12390
|
session,
|
|
12129
12391
|
currentPath: req.originalUrl.split("?")[0] ?? req.path,
|
|
12130
|
-
navModels:
|
|
12392
|
+
navModels: models.map((admin) => ({
|
|
12131
12393
|
label: admin.verboseNamePlural(),
|
|
12132
12394
|
url: `${prefix}/m/${admin.slug()}`
|
|
12133
12395
|
})),
|
|
12134
12396
|
messages: flashFor(req)
|
|
12135
12397
|
});
|
|
12398
|
+
const allows = async (principal, admin, action) => {
|
|
12399
|
+
if (!flagAllows(admin, action)) return false;
|
|
12400
|
+
if (options.accessPolicy === void 0) return true;
|
|
12401
|
+
return Boolean(await options.accessPolicy(principal, admin, action));
|
|
12402
|
+
};
|
|
12136
12403
|
const flashFor = (req) => {
|
|
12137
|
-
const
|
|
12138
|
-
|
|
12404
|
+
const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
|
|
12405
|
+
if (fixed !== void 0) return [fixed];
|
|
12406
|
+
const text = queryString(req.query.flash);
|
|
12407
|
+
if (text === "") return [];
|
|
12408
|
+
const level = queryString(req.query.level);
|
|
12409
|
+
return [
|
|
12410
|
+
{
|
|
12411
|
+
text: text.slice(0, FLASH_MAX_LENGTH),
|
|
12412
|
+
level: level === "error" || level === "warning" ? level : "success"
|
|
12413
|
+
}
|
|
12414
|
+
];
|
|
12139
12415
|
};
|
|
12140
12416
|
const html = (res, body, status = 200) => {
|
|
12141
12417
|
res.status(status).type("html").send(body);
|
|
@@ -12171,14 +12447,28 @@ function makeAdminRouter(site, options) {
|
|
|
12171
12447
|
res.redirect(`${prefix}/mfa`);
|
|
12172
12448
|
return null;
|
|
12173
12449
|
}
|
|
12174
|
-
|
|
12450
|
+
const visible = [];
|
|
12451
|
+
for (const admin of site.list()) {
|
|
12452
|
+
if (await allows(principal, admin, AdminPermission.VIEW)) visible.push(admin);
|
|
12453
|
+
}
|
|
12454
|
+
return { session, dbSession, principal, visible };
|
|
12175
12455
|
};
|
|
12176
|
-
const resolveAdmin = (req, res, state) => {
|
|
12456
|
+
const resolveAdmin = async (req, res, state, action = AdminPermission.VIEW) => {
|
|
12177
12457
|
const admin = site.get(String(req.params.slug));
|
|
12178
|
-
if (admin === null) {
|
|
12179
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12458
|
+
if (admin === null || !await allows(state.principal, admin, AdminPermission.VIEW)) {
|
|
12459
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12180
12460
|
return null;
|
|
12181
12461
|
}
|
|
12462
|
+
if (action !== AdminPermission.VIEW) {
|
|
12463
|
+
if (!flagAllows(admin, action)) {
|
|
12464
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12465
|
+
return null;
|
|
12466
|
+
}
|
|
12467
|
+
if (!await allows(state.principal, admin, action)) {
|
|
12468
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12469
|
+
return null;
|
|
12470
|
+
}
|
|
12471
|
+
}
|
|
12182
12472
|
return admin;
|
|
12183
12473
|
};
|
|
12184
12474
|
const renderNotFound = (ctx) => renderDashboardPage(
|
|
@@ -12189,7 +12479,7 @@ function makeAdminRouter(site, options) {
|
|
|
12189
12479
|
const checkCsrf = (req, res, state) => {
|
|
12190
12480
|
const body = req.body;
|
|
12191
12481
|
if (csrfTokenMatches(state.session, body?.csrf_token)) return true;
|
|
12192
|
-
html(res, renderNotFound(context(req, state.session)), 403);
|
|
12482
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12193
12483
|
return false;
|
|
12194
12484
|
};
|
|
12195
12485
|
router.get(`${prefix}/static/admin.css`, (_req, res) => {
|
|
@@ -12271,7 +12561,7 @@ function makeAdminRouter(site, options) {
|
|
|
12271
12561
|
const state = await authenticate(req, res);
|
|
12272
12562
|
if (state === null) return;
|
|
12273
12563
|
const cards = [];
|
|
12274
|
-
for (const admin of
|
|
12564
|
+
for (const admin of state.visible) {
|
|
12275
12565
|
let count = null;
|
|
12276
12566
|
try {
|
|
12277
12567
|
count = await admin.repository(state.dbSession).count();
|
|
@@ -12285,9 +12575,13 @@ function makeAdminRouter(site, options) {
|
|
|
12285
12575
|
label: admin.verboseNamePlural(),
|
|
12286
12576
|
count,
|
|
12287
12577
|
url: `${prefix}/m/${admin.slug()}`,
|
|
12288
|
-
newUrl: admin.
|
|
12578
|
+
newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null
|
|
12289
12579
|
});
|
|
12290
12580
|
}
|
|
12581
|
+
const businessCards = [];
|
|
12582
|
+
for (const card of site.dashboardCards) {
|
|
12583
|
+
businessCards.push(await computeBusinessCard(card, state.dbSession));
|
|
12584
|
+
}
|
|
12291
12585
|
let metrics = null;
|
|
12292
12586
|
if (showMetrics) {
|
|
12293
12587
|
const snapshot2 = MetricsUtils.system();
|
|
@@ -12298,7 +12592,15 @@ function makeAdminRouter(site, options) {
|
|
|
12298
12592
|
memoryTotalGb: (snapshot2.memory.total / 1024 ** 3).toFixed(1)
|
|
12299
12593
|
};
|
|
12300
12594
|
}
|
|
12301
|
-
html(
|
|
12595
|
+
html(
|
|
12596
|
+
res,
|
|
12597
|
+
renderDashboardPage(
|
|
12598
|
+
context(req, state.session, state.visible),
|
|
12599
|
+
cards,
|
|
12600
|
+
metrics,
|
|
12601
|
+
businessCards
|
|
12602
|
+
)
|
|
12603
|
+
);
|
|
12302
12604
|
})
|
|
12303
12605
|
);
|
|
12304
12606
|
router.get(
|
|
@@ -12306,7 +12608,7 @@ function makeAdminRouter(site, options) {
|
|
|
12306
12608
|
guarded(async (req, res) => {
|
|
12307
12609
|
const state = await authenticate(req, res);
|
|
12308
12610
|
if (state === null) return;
|
|
12309
|
-
const admin = resolveAdmin(req, res, state);
|
|
12611
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12310
12612
|
if (admin === null) return;
|
|
12311
12613
|
html(res, await renderList(req, admin, state));
|
|
12312
12614
|
})
|
|
@@ -12316,18 +12618,20 @@ function makeAdminRouter(site, options) {
|
|
|
12316
12618
|
guarded(async (req, res) => {
|
|
12317
12619
|
const state = await authenticate(req, res);
|
|
12318
12620
|
if (state === null) return;
|
|
12319
|
-
const admin = resolveAdmin(req, res, state);
|
|
12621
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
|
|
12320
12622
|
if (admin === null) return;
|
|
12321
12623
|
if (!admin.canCreate) {
|
|
12322
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12624
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12323
12625
|
return;
|
|
12324
12626
|
}
|
|
12325
12627
|
html(
|
|
12326
12628
|
res,
|
|
12327
|
-
renderFormPage(context(req, state.session), {
|
|
12629
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12328
12630
|
mode: "create",
|
|
12329
12631
|
title: admin.verboseName(),
|
|
12330
|
-
fields: buildFormFields(admin
|
|
12632
|
+
fields: buildFormFields(admin, {
|
|
12633
|
+
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
|
|
12634
|
+
}),
|
|
12331
12635
|
actionUrl: `${prefix}/m/${admin.slug()}/new`,
|
|
12332
12636
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
12333
12637
|
error: null
|
|
@@ -12340,22 +12644,27 @@ function makeAdminRouter(site, options) {
|
|
|
12340
12644
|
guarded(async (req, res) => {
|
|
12341
12645
|
const state = await authenticate(req, res);
|
|
12342
12646
|
if (state === null) return;
|
|
12343
|
-
const admin = resolveAdmin(req, res, state);
|
|
12647
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
|
|
12344
12648
|
if (admin === null) return;
|
|
12345
12649
|
if (!admin.canCreate) {
|
|
12346
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12650
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12347
12651
|
return;
|
|
12348
12652
|
}
|
|
12349
12653
|
if (!checkCsrf(req, res, state)) return;
|
|
12350
12654
|
const body = req.body;
|
|
12351
12655
|
const parsed = parseFormBody(admin, body);
|
|
12656
|
+
const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
|
|
12352
12657
|
const rerender = (error, status) => {
|
|
12353
12658
|
html(
|
|
12354
12659
|
res,
|
|
12355
|
-
renderFormPage(context(req, state.session), {
|
|
12660
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12356
12661
|
mode: "create",
|
|
12357
12662
|
title: admin.verboseName(),
|
|
12358
|
-
fields: buildFormFields(admin, {
|
|
12663
|
+
fields: buildFormFields(admin, {
|
|
12664
|
+
values: body,
|
|
12665
|
+
errors: parsed.errors,
|
|
12666
|
+
foreignKeyOptions
|
|
12667
|
+
}),
|
|
12359
12668
|
actionUrl: `${prefix}/m/${admin.slug()}/new`,
|
|
12360
12669
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
12361
12670
|
error
|
|
@@ -12367,6 +12676,7 @@ function makeAdminRouter(site, options) {
|
|
|
12367
12676
|
rerender("Please fix the highlighted fields.", 400);
|
|
12368
12677
|
return;
|
|
12369
12678
|
}
|
|
12679
|
+
stampActor(admin, parsed.data, backend.principalId(state.principal), true);
|
|
12370
12680
|
try {
|
|
12371
12681
|
await admin.repository(state.dbSession).create(parsed.data);
|
|
12372
12682
|
} catch (error) {
|
|
@@ -12376,28 +12686,134 @@ function makeAdminRouter(site, options) {
|
|
|
12376
12686
|
res.redirect(`${prefix}/m/${admin.slug()}?ok=created`);
|
|
12377
12687
|
})
|
|
12378
12688
|
);
|
|
12689
|
+
router.get(
|
|
12690
|
+
`${prefix}/m/:slug/export.:format`,
|
|
12691
|
+
guarded(async (req, res) => {
|
|
12692
|
+
const state = await authenticate(req, res);
|
|
12693
|
+
if (state === null) return;
|
|
12694
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12695
|
+
if (admin === null) return;
|
|
12696
|
+
const format = String(req.params.format);
|
|
12697
|
+
if (format !== "csv" && format !== "json") {
|
|
12698
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12699
|
+
return;
|
|
12700
|
+
}
|
|
12701
|
+
const query = await resolveListQuery(req, admin, state.dbSession);
|
|
12702
|
+
const result = await admin.repository(state.dbSession).paginate({
|
|
12703
|
+
page: 1,
|
|
12704
|
+
pageSize: exportMaxRows,
|
|
12705
|
+
...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
|
|
12706
|
+
ascending: query.ascending,
|
|
12707
|
+
...query.where === void 0 ? {} : { filters: query.where }
|
|
12708
|
+
});
|
|
12709
|
+
const columns = admin.listDisplayNames();
|
|
12710
|
+
const rows = result.items;
|
|
12711
|
+
const payload = format === "csv" ? toCsv(columns, rows) : toJson(columns, rows);
|
|
12712
|
+
res.status(200).type(format === "csv" ? "text/csv; charset=utf-8" : "application/json").set("content-disposition", `attachment; filename="${admin.slug()}.${format}"`).send(payload);
|
|
12713
|
+
})
|
|
12714
|
+
);
|
|
12715
|
+
router.post(
|
|
12716
|
+
`${prefix}/m/:slug/bulk`,
|
|
12717
|
+
guarded(async (req, res) => {
|
|
12718
|
+
const state = await authenticate(req, res);
|
|
12719
|
+
if (state === null) return;
|
|
12720
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12721
|
+
if (admin === null) return;
|
|
12722
|
+
if (!checkCsrf(req, res, state)) return;
|
|
12723
|
+
const body = req.body;
|
|
12724
|
+
const action = typeof body.action === "string" ? body.action : "";
|
|
12725
|
+
const raw = body.ids;
|
|
12726
|
+
const ids = (Array.isArray(raw) ? raw : raw === void 0 ? [] : [raw]).filter((value) => typeof value === "string").filter((value) => value !== "");
|
|
12727
|
+
const listUrl = `${prefix}/m/${admin.slug()}`;
|
|
12728
|
+
const back = (message, level) => {
|
|
12729
|
+
res.redirect(`${listUrl}?${buildQuery({ flash: message, level })}`);
|
|
12730
|
+
};
|
|
12731
|
+
if (ids.length === 0) {
|
|
12732
|
+
back("No rows were selected.", "warning");
|
|
12733
|
+
return;
|
|
12734
|
+
}
|
|
12735
|
+
if (!bulkActionsFor(admin).some((option) => option.value === action)) {
|
|
12736
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 400);
|
|
12737
|
+
return;
|
|
12738
|
+
}
|
|
12739
|
+
const needed = action === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
|
|
12740
|
+
if (!await allows(state.principal, admin, needed)) {
|
|
12741
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12742
|
+
return;
|
|
12743
|
+
}
|
|
12744
|
+
const repository = admin.repository(state.dbSession);
|
|
12745
|
+
const scope = { [admin.identityField]: { in: ids } };
|
|
12746
|
+
if (action.startsWith("custom:")) {
|
|
12747
|
+
const custom = admin.getAction(action.slice("custom:".length));
|
|
12748
|
+
if (custom === null) {
|
|
12749
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 400);
|
|
12750
|
+
return;
|
|
12751
|
+
}
|
|
12752
|
+
let result;
|
|
12753
|
+
try {
|
|
12754
|
+
result = await runCustomAction(custom, {
|
|
12755
|
+
ids,
|
|
12756
|
+
repository,
|
|
12757
|
+
dbSession: state.dbSession,
|
|
12758
|
+
request: req,
|
|
12759
|
+
session: state.session,
|
|
12760
|
+
principal: state.principal
|
|
12761
|
+
});
|
|
12762
|
+
} catch (error) {
|
|
12763
|
+
logger2.error("Admin action failed", {
|
|
12764
|
+
slug: admin.slug(),
|
|
12765
|
+
action: custom.name,
|
|
12766
|
+
error: error instanceof Error ? error.message : String(error)
|
|
12767
|
+
});
|
|
12768
|
+
back(
|
|
12769
|
+
`${custom.label} failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
12770
|
+
"error"
|
|
12771
|
+
);
|
|
12772
|
+
return;
|
|
12773
|
+
}
|
|
12774
|
+
if (result === null) {
|
|
12775
|
+
res.redirect(listUrl);
|
|
12776
|
+
return;
|
|
12777
|
+
}
|
|
12778
|
+
back(result.message, result.category ?? "success");
|
|
12779
|
+
return;
|
|
12780
|
+
}
|
|
12781
|
+
if (action === "delete") {
|
|
12782
|
+
const removed = await repository.delete(scope);
|
|
12783
|
+
back(`Deleted ${removed} record${removed === 1 ? "" : "s"}.`, "success");
|
|
12784
|
+
return;
|
|
12785
|
+
}
|
|
12786
|
+
const active = action === "activate";
|
|
12787
|
+
const changed = await repository.update(scope, { isActive: active });
|
|
12788
|
+
back(
|
|
12789
|
+
`${active ? "Activated" : "Deactivated"} ${changed} record${changed === 1 ? "" : "s"}.`,
|
|
12790
|
+
"success"
|
|
12791
|
+
);
|
|
12792
|
+
})
|
|
12793
|
+
);
|
|
12379
12794
|
router.get(
|
|
12380
12795
|
`${prefix}/m/:slug/:identity`,
|
|
12381
12796
|
guarded(async (req, res) => {
|
|
12382
12797
|
const state = await authenticate(req, res);
|
|
12383
12798
|
if (state === null) return;
|
|
12384
|
-
const admin = resolveAdmin(req, res, state);
|
|
12799
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12385
12800
|
if (admin === null) return;
|
|
12386
12801
|
const row = await findRow(admin, state.dbSession, String(req.params.identity));
|
|
12387
12802
|
if (row === null) {
|
|
12388
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12803
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12389
12804
|
return;
|
|
12390
12805
|
}
|
|
12391
12806
|
const identity = String(row[admin.identityField]);
|
|
12392
12807
|
html(
|
|
12393
12808
|
res,
|
|
12394
|
-
renderDetailPage(context(req, state.session), {
|
|
12809
|
+
renderDetailPage(context(req, state.session, state.visible), {
|
|
12395
12810
|
title: admin.verboseName(),
|
|
12396
12811
|
identity,
|
|
12812
|
+
audit: await buildAuditView(admin, row, state.dbSession),
|
|
12397
12813
|
fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
|
|
12398
12814
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
12399
|
-
editUrl: admin.
|
|
12400
|
-
deleteUrl: admin.
|
|
12815
|
+
editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
|
|
12816
|
+
deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
|
|
12401
12817
|
})
|
|
12402
12818
|
);
|
|
12403
12819
|
})
|
|
@@ -12407,20 +12823,23 @@ function makeAdminRouter(site, options) {
|
|
|
12407
12823
|
guarded(async (req, res) => {
|
|
12408
12824
|
const state = await authenticate(req, res);
|
|
12409
12825
|
if (state === null) return;
|
|
12410
|
-
const admin = resolveAdmin(req, res, state);
|
|
12826
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
|
|
12411
12827
|
if (admin === null) return;
|
|
12412
12828
|
const identity = String(req.params.identity);
|
|
12413
12829
|
const row = admin.canEdit ? await findRow(admin, state.dbSession, identity) : null;
|
|
12414
12830
|
if (row === null) {
|
|
12415
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12831
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12416
12832
|
return;
|
|
12417
12833
|
}
|
|
12418
12834
|
html(
|
|
12419
12835
|
res,
|
|
12420
|
-
renderFormPage(context(req, state.session), {
|
|
12836
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12421
12837
|
mode: "edit",
|
|
12422
12838
|
title: admin.verboseName(),
|
|
12423
|
-
fields: buildFormFields(admin, {
|
|
12839
|
+
fields: buildFormFields(admin, {
|
|
12840
|
+
values: row,
|
|
12841
|
+
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
|
|
12842
|
+
}),
|
|
12424
12843
|
actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
|
|
12425
12844
|
backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
|
|
12426
12845
|
error: null
|
|
@@ -12433,23 +12852,28 @@ function makeAdminRouter(site, options) {
|
|
|
12433
12852
|
guarded(async (req, res) => {
|
|
12434
12853
|
const state = await authenticate(req, res);
|
|
12435
12854
|
if (state === null) return;
|
|
12436
|
-
const admin = resolveAdmin(req, res, state);
|
|
12855
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
|
|
12437
12856
|
if (admin === null) return;
|
|
12438
12857
|
const identity = String(req.params.identity);
|
|
12439
12858
|
if (!admin.canEdit) {
|
|
12440
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12859
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12441
12860
|
return;
|
|
12442
12861
|
}
|
|
12443
12862
|
if (!checkCsrf(req, res, state)) return;
|
|
12444
12863
|
const body = req.body;
|
|
12445
12864
|
const parsed = parseFormBody(admin, body);
|
|
12865
|
+
const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
|
|
12446
12866
|
const rerender = (error, status) => {
|
|
12447
12867
|
html(
|
|
12448
12868
|
res,
|
|
12449
|
-
renderFormPage(context(req, state.session), {
|
|
12869
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12450
12870
|
mode: "edit",
|
|
12451
12871
|
title: admin.verboseName(),
|
|
12452
|
-
fields: buildFormFields(admin, {
|
|
12872
|
+
fields: buildFormFields(admin, {
|
|
12873
|
+
values: body,
|
|
12874
|
+
errors: parsed.errors,
|
|
12875
|
+
foreignKeyOptions
|
|
12876
|
+
}),
|
|
12453
12877
|
actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
|
|
12454
12878
|
backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
|
|
12455
12879
|
error
|
|
@@ -12461,10 +12885,16 @@ function makeAdminRouter(site, options) {
|
|
|
12461
12885
|
rerender("Please fix the highlighted fields.", 400);
|
|
12462
12886
|
return;
|
|
12463
12887
|
}
|
|
12888
|
+
stampActor(
|
|
12889
|
+
admin,
|
|
12890
|
+
parsed.data,
|
|
12891
|
+
backend.principalId(state.principal),
|
|
12892
|
+
false
|
|
12893
|
+
);
|
|
12464
12894
|
try {
|
|
12465
12895
|
const changed = await admin.repository(state.dbSession).update({ [admin.identityField]: identity }, parsed.data);
|
|
12466
12896
|
if (changed === 0) {
|
|
12467
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12897
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12468
12898
|
return;
|
|
12469
12899
|
}
|
|
12470
12900
|
} catch (error) {
|
|
@@ -12479,10 +12909,10 @@ function makeAdminRouter(site, options) {
|
|
|
12479
12909
|
guarded(async (req, res) => {
|
|
12480
12910
|
const state = await authenticate(req, res);
|
|
12481
12911
|
if (state === null) return;
|
|
12482
|
-
const admin = resolveAdmin(req, res, state);
|
|
12912
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.DELETE);
|
|
12483
12913
|
if (admin === null) return;
|
|
12484
12914
|
if (!admin.canDelete) {
|
|
12485
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12915
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12486
12916
|
return;
|
|
12487
12917
|
}
|
|
12488
12918
|
if (!checkCsrf(req, res, state)) return;
|
|
@@ -12490,18 +12920,142 @@ function makeAdminRouter(site, options) {
|
|
|
12490
12920
|
res.redirect(`${prefix}/m/${admin.slug()}?ok=deleted`);
|
|
12491
12921
|
})
|
|
12492
12922
|
);
|
|
12923
|
+
async function actorLabel(actor, dbSession) {
|
|
12924
|
+
if (actor === null || actor === void 0 || actor === "") return "";
|
|
12925
|
+
const principal = await backend.loadPrincipal(dbSession, String(actor));
|
|
12926
|
+
return principal === null ? String(actor) : backend.displayName(principal);
|
|
12927
|
+
}
|
|
12928
|
+
async function buildAuditView(admin, row, dbSession) {
|
|
12929
|
+
const fields = [];
|
|
12930
|
+
for (const name of admin.auditFieldNames()) {
|
|
12931
|
+
const value = name === "createdBy" || name === "updatedBy" ? await actorLabel(row[name], dbSession) : formatCellValue(row[name]);
|
|
12932
|
+
fields.push({ label: humanizeField(name), value });
|
|
12933
|
+
}
|
|
12934
|
+
const history = [];
|
|
12935
|
+
if (admin.auditModel !== null) {
|
|
12936
|
+
const entity = admin.model.name;
|
|
12937
|
+
const entityId = String(row[admin.identityField]);
|
|
12938
|
+
const page2 = await new tempestDbJs.BaseRepository(admin.auditModel, dbSession).paginate({
|
|
12939
|
+
page: 1,
|
|
12940
|
+
pageSize: AUDIT_HISTORY_LIMIT,
|
|
12941
|
+
orderBy: "createdAt",
|
|
12942
|
+
ascending: false,
|
|
12943
|
+
filters: { entity, entityId }
|
|
12944
|
+
});
|
|
12945
|
+
for (const entry of page2.items) {
|
|
12946
|
+
const changes = entry.changes ?? {};
|
|
12947
|
+
history.push({
|
|
12948
|
+
action: String(entry.action ?? ""),
|
|
12949
|
+
at: formatCellValue(entry.createdAt),
|
|
12950
|
+
actor: await actorLabel(entry.actor, dbSession) || "\u2014",
|
|
12951
|
+
changes: Object.entries(changes).map(([field, change]) => ({
|
|
12952
|
+
field,
|
|
12953
|
+
before: formatCellValue(change?.before),
|
|
12954
|
+
after: formatCellValue(change?.after)
|
|
12955
|
+
})),
|
|
12956
|
+
context: entry.context === null || entry.context === void 0 ? null : JSON.stringify(entry.context, null, 2)
|
|
12957
|
+
});
|
|
12958
|
+
}
|
|
12959
|
+
}
|
|
12960
|
+
if (fields.length === 0 && history.length === 0) return null;
|
|
12961
|
+
return { fields, history };
|
|
12962
|
+
}
|
|
12493
12963
|
async function findRow(admin, dbSession, identity) {
|
|
12494
12964
|
const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
|
|
12495
12965
|
return row ?? null;
|
|
12496
12966
|
}
|
|
12967
|
+
async function permittedBulkActions(admin, principal) {
|
|
12968
|
+
const permitted = [];
|
|
12969
|
+
for (const option of bulkActionsFor(admin)) {
|
|
12970
|
+
const needed = option.value === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
|
|
12971
|
+
if (await allows(principal, admin, needed)) permitted.push(option);
|
|
12972
|
+
}
|
|
12973
|
+
return permitted;
|
|
12974
|
+
}
|
|
12497
12975
|
async function renderList(req, admin, state) {
|
|
12498
12976
|
const columns = adminColumns(admin.model);
|
|
12499
|
-
const
|
|
12977
|
+
const query = await resolveListQuery(req, admin, state.dbSession);
|
|
12500
12978
|
const page2 = Math.max(1, Number.parseInt(queryString(req.query.page), 10) || 1);
|
|
12979
|
+
const result = await admin.repository(state.dbSession).paginate({
|
|
12980
|
+
page: page2,
|
|
12981
|
+
pageSize: admin.pageSize,
|
|
12982
|
+
...query.orderBy === void 0 ? {} : { orderBy: query.orderBy },
|
|
12983
|
+
ascending: query.ascending,
|
|
12984
|
+
...query.where === void 0 ? {} : { filters: query.where }
|
|
12985
|
+
});
|
|
12986
|
+
const displayed = admin.listDisplayNames();
|
|
12987
|
+
const sort = {};
|
|
12988
|
+
for (const column6 of displayed) {
|
|
12989
|
+
if (!(column6 in columns)) continue;
|
|
12990
|
+
const active = (query.sortColumn ?? admin.orderKey) === column6;
|
|
12991
|
+
const nextAscending = active ? !query.ascending : true;
|
|
12992
|
+
sort[column6] = {
|
|
12993
|
+
url: `?${buildQuery({
|
|
12994
|
+
...query.baseQuery,
|
|
12995
|
+
sort: column6,
|
|
12996
|
+
dir: nextAscending ? "asc" : "desc"
|
|
12997
|
+
})}`,
|
|
12998
|
+
active,
|
|
12999
|
+
ascending: query.ascending
|
|
13000
|
+
};
|
|
13001
|
+
}
|
|
13002
|
+
const sortParams = {
|
|
13003
|
+
sort: query.sortColumn ?? void 0,
|
|
13004
|
+
dir: query.sortColumn === null ? void 0 : query.ascending ? "asc" : "desc"
|
|
13005
|
+
};
|
|
13006
|
+
const pageUrl = (target) => `?${buildQuery({ ...query.baseQuery, ...sortParams, page: target })}`;
|
|
13007
|
+
const exportQuery = buildQuery({ ...query.baseQuery, ...sortParams });
|
|
13008
|
+
const exportUrl = (format) => `${prefix}/m/${admin.slug()}/export.${format}${exportQuery === "" ? "" : `?${exportQuery}`}`;
|
|
13009
|
+
const view = {
|
|
13010
|
+
title: admin.verboseNamePlural(),
|
|
13011
|
+
columns: displayed,
|
|
13012
|
+
rows: result.items.map((row) => {
|
|
13013
|
+
const identity = String(row[admin.identityField]);
|
|
13014
|
+
return {
|
|
13015
|
+
identity,
|
|
13016
|
+
cells: displayed.map((column6) => formatCellValue(row[column6])),
|
|
13017
|
+
url: `${prefix}/m/${admin.slug()}/${identity}`
|
|
13018
|
+
};
|
|
13019
|
+
}),
|
|
13020
|
+
total: result.total,
|
|
13021
|
+
page: result.page,
|
|
13022
|
+
pages: result.pages,
|
|
13023
|
+
prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
|
|
13024
|
+
nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
|
|
13025
|
+
searchable: query.searchable.length > 0,
|
|
13026
|
+
searchValue: query.search,
|
|
13027
|
+
filters: query.filterViews,
|
|
13028
|
+
sort,
|
|
13029
|
+
newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
|
|
13030
|
+
bulkActions: await permittedBulkActions(admin, state.principal),
|
|
13031
|
+
bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
|
|
13032
|
+
exportCsvUrl: exportUrl("csv"),
|
|
13033
|
+
exportJsonUrl: exportUrl("json"),
|
|
13034
|
+
lenses: admin.lenses.length === 0 ? [] : [
|
|
13035
|
+
{
|
|
13036
|
+
label: "All",
|
|
13037
|
+
url: `?${buildQuery({ ...query.baseQuery, lens: void 0 })}`,
|
|
13038
|
+
active: query.lens === ""
|
|
13039
|
+
},
|
|
13040
|
+
...admin.lenses.map((entry) => ({
|
|
13041
|
+
label: entry.label,
|
|
13042
|
+
url: `?${buildQuery({ ...query.baseQuery, lens: entry.slug })}`,
|
|
13043
|
+
active: query.lens === entry.slug
|
|
13044
|
+
}))
|
|
13045
|
+
]
|
|
13046
|
+
};
|
|
13047
|
+
return renderListPage(context(req, state.session, state.visible), view);
|
|
13048
|
+
}
|
|
13049
|
+
async function resolveListQuery(req, admin, dbSession) {
|
|
13050
|
+
const columns = adminColumns(admin.model);
|
|
13051
|
+
const search = queryString(req.query.q);
|
|
12501
13052
|
const sortField = queryString(req.query.sort);
|
|
12502
13053
|
const sortColumn = sortField in columns ? sortField : null;
|
|
12503
|
-
const
|
|
13054
|
+
const lens = admin.getLens(queryString(req.query.lens));
|
|
12504
13055
|
const conditions = [];
|
|
13056
|
+
if (lens !== null && Object.keys(lens.filters).length > 0) {
|
|
13057
|
+
conditions.push(lens.filters);
|
|
13058
|
+
}
|
|
12505
13059
|
const filterViews = [];
|
|
12506
13060
|
for (const field of admin.listFilter) {
|
|
12507
13061
|
const column6 = columns[field];
|
|
@@ -12525,6 +13079,8 @@ function makeAdminRouter(site, options) {
|
|
|
12525
13079
|
});
|
|
12526
13080
|
continue;
|
|
12527
13081
|
}
|
|
13082
|
+
const related = await relatedOptions(column6, dbSession);
|
|
13083
|
+
const options2 = related ?? spec.options;
|
|
12528
13084
|
const value = queryString(req.query[`filter_${field}`]);
|
|
12529
13085
|
if (value !== "") {
|
|
12530
13086
|
conditions.push({
|
|
@@ -12534,11 +13090,11 @@ function makeAdminRouter(site, options) {
|
|
|
12534
13090
|
filterViews.push({
|
|
12535
13091
|
field,
|
|
12536
13092
|
label: humanizeField(field),
|
|
12537
|
-
kind: spec.kind,
|
|
13093
|
+
kind: related === null ? spec.kind : "select",
|
|
12538
13094
|
value,
|
|
12539
13095
|
valueFrom: "",
|
|
12540
13096
|
valueTo: "",
|
|
12541
|
-
options:
|
|
13097
|
+
options: options2.map((option) => ({
|
|
12542
13098
|
value: option.value,
|
|
12543
13099
|
label: option.label,
|
|
12544
13100
|
selected: option.value === value
|
|
@@ -12554,72 +13110,174 @@ function makeAdminRouter(site, options) {
|
|
|
12554
13110
|
tempestDbJs.or(...searchable.map((field) => ({ [field]: { ilike: `%${search}%` } })))
|
|
12555
13111
|
);
|
|
12556
13112
|
}
|
|
12557
|
-
const where = conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions);
|
|
12558
|
-
const orderBy = sortColumn ?? admin.orderKey ?? void 0;
|
|
12559
|
-
const result = await admin.repository(state.dbSession).paginate({
|
|
12560
|
-
page: page2,
|
|
12561
|
-
pageSize: admin.pageSize,
|
|
12562
|
-
...orderBy === void 0 ? {} : { orderBy },
|
|
12563
|
-
ascending,
|
|
12564
|
-
...where === void 0 ? {} : { filters: where }
|
|
12565
|
-
});
|
|
12566
|
-
const displayed = admin.listDisplayNames();
|
|
12567
13113
|
const baseQuery = { q: search };
|
|
12568
|
-
for (const
|
|
12569
|
-
if (
|
|
12570
|
-
baseQuery[`filter_${
|
|
12571
|
-
baseQuery[`filter_${
|
|
13114
|
+
for (const view of filterViews) {
|
|
13115
|
+
if (view.kind === "daterange") {
|
|
13116
|
+
baseQuery[`filter_${view.field}_from`] = view.valueFrom;
|
|
13117
|
+
baseQuery[`filter_${view.field}_to`] = view.valueTo;
|
|
12572
13118
|
} else {
|
|
12573
|
-
baseQuery[`filter_${
|
|
13119
|
+
baseQuery[`filter_${view.field}`] = view.value;
|
|
12574
13120
|
}
|
|
12575
13121
|
}
|
|
12576
|
-
const
|
|
12577
|
-
|
|
12578
|
-
|
|
12579
|
-
|
|
12580
|
-
|
|
12581
|
-
|
|
12582
|
-
|
|
12583
|
-
|
|
12584
|
-
|
|
12585
|
-
|
|
12586
|
-
|
|
12587
|
-
|
|
12588
|
-
|
|
12589
|
-
|
|
12590
|
-
|
|
12591
|
-
const pageUrl = (target) => `?${buildQuery({
|
|
12592
|
-
...baseQuery,
|
|
12593
|
-
sort: sortColumn ?? void 0,
|
|
12594
|
-
dir: sortColumn === null ? void 0 : ascending ? "asc" : "desc",
|
|
12595
|
-
page: target
|
|
12596
|
-
})}`;
|
|
12597
|
-
const view = {
|
|
12598
|
-
title: admin.verboseNamePlural(),
|
|
12599
|
-
columns: displayed,
|
|
12600
|
-
rows: result.items.map((row) => {
|
|
12601
|
-
const identity = String(row[admin.identityField]);
|
|
12602
|
-
return {
|
|
12603
|
-
identity,
|
|
12604
|
-
cells: displayed.map((column6) => formatCellValue(row[column6])),
|
|
12605
|
-
url: `${prefix}/m/${admin.slug()}/${identity}`
|
|
12606
|
-
};
|
|
12607
|
-
}),
|
|
12608
|
-
total: result.total,
|
|
12609
|
-
page: result.page,
|
|
12610
|
-
pages: result.pages,
|
|
12611
|
-
prevUrl: result.page > 1 ? pageUrl(result.page - 1) : null,
|
|
12612
|
-
nextUrl: result.page < result.pages ? pageUrl(result.page + 1) : null,
|
|
12613
|
-
searchable: searchable.length > 0,
|
|
12614
|
-
searchValue: search,
|
|
12615
|
-
filters: filterViews,
|
|
12616
|
-
sort,
|
|
12617
|
-
newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
|
|
13122
|
+
const lensOrder = lens?.orderBy ?? null;
|
|
13123
|
+
const lensDescending = lensOrder?.startsWith("-");
|
|
13124
|
+
const lensColumn = lensOrder === null ? null : lensOrder.replace(/^-/, "");
|
|
13125
|
+
const ascending = sortColumn !== null ? queryString(req.query.dir) !== "desc" : lensColumn !== null ? !lensDescending : admin.orderAscending;
|
|
13126
|
+
if (lens !== null) baseQuery.lens = lens.slug;
|
|
13127
|
+
return {
|
|
13128
|
+
search,
|
|
13129
|
+
searchable,
|
|
13130
|
+
where: conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions),
|
|
13131
|
+
orderBy: sortColumn ?? lensColumn ?? admin.orderKey ?? void 0,
|
|
13132
|
+
ascending,
|
|
13133
|
+
sortColumn,
|
|
13134
|
+
filterViews,
|
|
13135
|
+
baseQuery,
|
|
13136
|
+
lens: lens?.slug ?? ""
|
|
12618
13137
|
};
|
|
12619
|
-
|
|
13138
|
+
}
|
|
13139
|
+
async function relatedOptions(column6, dbSession) {
|
|
13140
|
+
const table = foreignKeyTable(column6);
|
|
13141
|
+
if (table === null) return null;
|
|
13142
|
+
const referenced = site.get(table);
|
|
13143
|
+
if (referenced === null) return null;
|
|
13144
|
+
const rows = await referenced.repository(dbSession).list();
|
|
13145
|
+
return rows.slice(0, FK_OPTION_CAP).map((row) => ({
|
|
13146
|
+
value: String(row[referenced.identityField]),
|
|
13147
|
+
label: foreignKeyLabel(referenced, row)
|
|
13148
|
+
}));
|
|
13149
|
+
}
|
|
13150
|
+
async function foreignKeyOptionsFor(admin, dbSession) {
|
|
13151
|
+
const columns = adminColumns(admin.model);
|
|
13152
|
+
const options2 = {};
|
|
13153
|
+
for (const field of Object.keys(foreignKeyFields(admin))) {
|
|
13154
|
+
const column6 = columns[field];
|
|
13155
|
+
if (column6 === void 0) continue;
|
|
13156
|
+
const related = await relatedOptions(column6, dbSession);
|
|
13157
|
+
if (related !== null) options2[field] = related;
|
|
13158
|
+
}
|
|
13159
|
+
return options2;
|
|
12620
13160
|
}
|
|
12621
13161
|
return router;
|
|
12622
13162
|
}
|
|
13163
|
+
function flagAllows(admin, action) {
|
|
13164
|
+
if (action === AdminPermission.CREATE) return admin.canCreate;
|
|
13165
|
+
if (action === AdminPermission.EDIT) return admin.canEdit;
|
|
13166
|
+
if (action === AdminPermission.DELETE) return admin.canDelete;
|
|
13167
|
+
return true;
|
|
13168
|
+
}
|
|
13169
|
+
function stampActor(admin, data, actorId, creating) {
|
|
13170
|
+
const columns = adminColumns(admin.model);
|
|
13171
|
+
if (creating && "createdBy" in columns) data.createdBy = actorId;
|
|
13172
|
+
if ("updatedBy" in columns) data.updatedBy = actorId;
|
|
13173
|
+
}
|
|
13174
|
+
function formatMetric(value) {
|
|
13175
|
+
if (typeof value === "string") return value;
|
|
13176
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
13177
|
+
}
|
|
13178
|
+
async function computeBusinessCard(card, dbSession) {
|
|
13179
|
+
const base = {
|
|
13180
|
+
label: card.label,
|
|
13181
|
+
unit: null,
|
|
13182
|
+
direction: "flat",
|
|
13183
|
+
percent: null,
|
|
13184
|
+
previous: "",
|
|
13185
|
+
segments: [],
|
|
13186
|
+
helpText: card.helpText ?? null
|
|
13187
|
+
};
|
|
13188
|
+
let data;
|
|
13189
|
+
try {
|
|
13190
|
+
data = await card.compute(dbSession);
|
|
13191
|
+
} catch (error) {
|
|
13192
|
+
logger2.warning("Admin dashboard card failed", {
|
|
13193
|
+
card: card.label,
|
|
13194
|
+
error: error instanceof Error ? error.message : String(error)
|
|
13195
|
+
});
|
|
13196
|
+
return { ...base, kind: "value", value: "", error: "Could not compute this metric." };
|
|
13197
|
+
}
|
|
13198
|
+
if (data.kind === "partition") {
|
|
13199
|
+
const total = partitionTotal(data);
|
|
13200
|
+
return {
|
|
13201
|
+
...base,
|
|
13202
|
+
kind: "partition",
|
|
13203
|
+
value: formatMetric(total),
|
|
13204
|
+
segments: data.segments.map((segment) => ({
|
|
13205
|
+
label: segment.label,
|
|
13206
|
+
value: formatMetric(segment.value),
|
|
13207
|
+
percent: total === 0 ? 0 : segment.value / total * 100
|
|
13208
|
+
})),
|
|
13209
|
+
error: null
|
|
13210
|
+
};
|
|
13211
|
+
}
|
|
13212
|
+
if (data.kind === "trend") {
|
|
13213
|
+
const percent = trendPercent(data);
|
|
13214
|
+
return {
|
|
13215
|
+
...base,
|
|
13216
|
+
kind: "trend",
|
|
13217
|
+
value: formatMetric(data.value),
|
|
13218
|
+
unit: data.unit ?? null,
|
|
13219
|
+
direction: trendDirection(data),
|
|
13220
|
+
percent: percent === null ? null : `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`,
|
|
13221
|
+
previous: formatMetric(data.previous),
|
|
13222
|
+
error: null
|
|
13223
|
+
};
|
|
13224
|
+
}
|
|
13225
|
+
return {
|
|
13226
|
+
...base,
|
|
13227
|
+
kind: "value",
|
|
13228
|
+
value: formatMetric(data.value),
|
|
13229
|
+
unit: data.unit ?? null,
|
|
13230
|
+
error: null
|
|
13231
|
+
};
|
|
13232
|
+
}
|
|
13233
|
+
function bulkActionsFor(admin) {
|
|
13234
|
+
const actions = [];
|
|
13235
|
+
const hasActiveFlag = "isActive" in adminColumns(admin.model);
|
|
13236
|
+
if (admin.canEdit && hasActiveFlag) {
|
|
13237
|
+
actions.push({ value: "activate", label: "Activate", dangerous: false });
|
|
13238
|
+
actions.push({ value: "deactivate", label: "Deactivate", dangerous: false });
|
|
13239
|
+
}
|
|
13240
|
+
if (admin.canDelete) {
|
|
13241
|
+
actions.push({ value: "delete", label: "Delete", dangerous: true });
|
|
13242
|
+
}
|
|
13243
|
+
for (const action of admin.customActions()) {
|
|
13244
|
+
actions.push({
|
|
13245
|
+
value: `custom:${action.name}`,
|
|
13246
|
+
label: action.label,
|
|
13247
|
+
dangerous: action.dangerous
|
|
13248
|
+
});
|
|
13249
|
+
}
|
|
13250
|
+
return actions;
|
|
13251
|
+
}
|
|
13252
|
+
function exportValue(value) {
|
|
13253
|
+
if (value instanceof Date) return value.toISOString();
|
|
13254
|
+
if (typeof value === "bigint") return value.toString();
|
|
13255
|
+
if (value instanceof Uint8Array) return Buffer.from(value).toString("base64");
|
|
13256
|
+
return value;
|
|
13257
|
+
}
|
|
13258
|
+
function csvField(value) {
|
|
13259
|
+
if (value === null || value === void 0) return "";
|
|
13260
|
+
const text = typeof value === "object" ? JSON.stringify(value) : String(value);
|
|
13261
|
+
if (/[",\r\n]/.test(text)) return `"${text.replace(/"/g, '""')}"`;
|
|
13262
|
+
return text;
|
|
13263
|
+
}
|
|
13264
|
+
function toCsv(columns, rows) {
|
|
13265
|
+
const lines = [columns.map(csvField).join(",")];
|
|
13266
|
+
for (const row of rows) {
|
|
13267
|
+
lines.push(columns.map((column6) => csvField(exportValue(row[column6]))).join(","));
|
|
13268
|
+
}
|
|
13269
|
+
return `${lines.join("\r\n")}\r
|
|
13270
|
+
`;
|
|
13271
|
+
}
|
|
13272
|
+
function toJson(columns, rows) {
|
|
13273
|
+
return JSON.stringify(
|
|
13274
|
+
rows.map(
|
|
13275
|
+
(row) => Object.fromEntries(columns.map((column6) => [column6, exportValue(row[column6])]))
|
|
13276
|
+
),
|
|
13277
|
+
null,
|
|
13278
|
+
2
|
|
13279
|
+
);
|
|
13280
|
+
}
|
|
12623
13281
|
function describeWriteFailure(admin, error) {
|
|
12624
13282
|
const detail = error instanceof Error ? error.message : String(error);
|
|
12625
13283
|
return `The database refused this ${admin.verboseName().toLowerCase()}: ${detail}`;
|
|
@@ -14624,7 +15282,7 @@ async function withTestDatabase(models, fn) {
|
|
|
14624
15282
|
}
|
|
14625
15283
|
|
|
14626
15284
|
// src/version.ts
|
|
14627
|
-
var VERSION = "0.
|
|
15285
|
+
var VERSION = "0.26.0";
|
|
14628
15286
|
|
|
14629
15287
|
Object.defineProperty(exports, "OpenAPIRegistry", {
|
|
14630
15288
|
enumerable: true,
|
|
@@ -14782,6 +15440,7 @@ exports.ADMIN_CSS = ADMIN_CSS;
|
|
|
14782
15440
|
exports.ActivationService = ActivationService;
|
|
14783
15441
|
exports.AdminJsonSite = AdminJsonSite;
|
|
14784
15442
|
exports.AdminModel = AdminModel;
|
|
15443
|
+
exports.AdminPermission = AdminPermission;
|
|
14785
15444
|
exports.AdminSessionStore = AdminSessionStore;
|
|
14786
15445
|
exports.AdminSite = AdminSite;
|
|
14787
15446
|
exports.AppException = AppException;
|
|
@@ -14880,7 +15539,9 @@ exports.WebhookSignatureVerifier = WebhookSignatureVerifier;
|
|
|
14880
15539
|
exports.WhatsAppProvider = WhatsAppProvider;
|
|
14881
15540
|
exports.activationSchema = activationSchema;
|
|
14882
15541
|
exports.addLogSink = addLogSink;
|
|
15542
|
+
exports.adminAction = adminAction;
|
|
14883
15543
|
exports.adminColumns = adminColumns;
|
|
15544
|
+
exports.adminLens = adminLens;
|
|
14884
15545
|
exports.adminThemeCss = adminThemeCss;
|
|
14885
15546
|
exports.attachWebSocketHub = attachWebSocketHub;
|
|
14886
15547
|
exports.authResponseSchema = authResponseSchema;
|
|
@@ -14926,6 +15587,9 @@ exports.envBoolean = looseBoolean;
|
|
|
14926
15587
|
exports.envList = envList;
|
|
14927
15588
|
exports.escapeHtml = escapeHtml;
|
|
14928
15589
|
exports.filterForColumn = filterForColumn;
|
|
15590
|
+
exports.foreignKeyFields = foreignKeyFields;
|
|
15591
|
+
exports.foreignKeyLabel = foreignKeyLabel;
|
|
15592
|
+
exports.foreignKeyTable = foreignKeyTable;
|
|
14929
15593
|
exports.formatCellValue = formatCellValue;
|
|
14930
15594
|
exports.formatFieldValue = formatFieldValue;
|
|
14931
15595
|
exports.generateCsrfToken = generateCsrfToken;
|
|
@@ -14979,6 +15643,7 @@ exports.makeToolSpecRouter = makeToolSpecRouter;
|
|
|
14979
15643
|
exports.makeTwilioWebhookRouter = makeTwilioWebhookRouter;
|
|
14980
15644
|
exports.makeUnhandledExceptionHandler = makeUnhandledExceptionHandler;
|
|
14981
15645
|
exports.makeWhatsAppWebhookRouter = makeWhatsAppWebhookRouter;
|
|
15646
|
+
exports.metricCard = metricCard;
|
|
14982
15647
|
exports.mfaChallengeSchema = mfaChallengeSchema;
|
|
14983
15648
|
exports.mfaCodeSchema = mfaCodeSchema;
|
|
14984
15649
|
exports.mfaEnrollResponseSchema = mfaEnrollResponseSchema;
|
|
@@ -15003,6 +15668,7 @@ exports.paginationSchema = paginationSchema;
|
|
|
15003
15668
|
exports.parseAcceptLanguage = parseAcceptLanguage;
|
|
15004
15669
|
exports.parseCookies = parseCookies;
|
|
15005
15670
|
exports.parseFormBody = parseFormBody;
|
|
15671
|
+
exports.partitionTotal = partitionTotal;
|
|
15006
15672
|
exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
|
|
15007
15673
|
exports.passwordResetRequestSchema = passwordResetRequestSchema;
|
|
15008
15674
|
exports.percentField = percentField;
|
|
@@ -15055,6 +15721,8 @@ exports.toUtc = toUtc;
|
|
|
15055
15721
|
exports.tokenFromUrl = tokenFromUrl;
|
|
15056
15722
|
exports.tokenPairSchema = tokenPairSchema;
|
|
15057
15723
|
exports.tokenSettingsShape = tokenSettingsShape;
|
|
15724
|
+
exports.trendDirection = trendDirection;
|
|
15725
|
+
exports.trendPercent = trendPercent;
|
|
15058
15726
|
exports.ufField = ufField;
|
|
15059
15727
|
exports.updatedByColumn = updatedByColumn;
|
|
15060
15728
|
exports.uploadSettingsShape = uploadSettingsShape;
|