tempest-express-sdk 0.25.0 → 0.27.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-TIW2KPT2.js → chunk-ADF7OHRH.js} +3 -3
- package/dist/{chunk-TIW2KPT2.js.map → chunk-ADF7OHRH.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 +907 -62
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +554 -96
- package/dist/index.d.ts +554 -96
- package/dist/index.js +898 -64
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
package/dist/index.cjs
CHANGED
|
@@ -9444,12 +9444,51 @@ 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
|
+
|
|
9447
9486
|
// src/admin/actions.ts
|
|
9448
|
-
function
|
|
9487
|
+
function slugify2(label) {
|
|
9449
9488
|
return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
|
|
9450
9489
|
}
|
|
9451
9490
|
function adminAction(options, handler) {
|
|
9452
|
-
const name = options.name ??
|
|
9491
|
+
const name = options.name ?? slugify2(options.label);
|
|
9453
9492
|
if (name === "") throw new Error("adminAction requires a non-empty name or label");
|
|
9454
9493
|
return {
|
|
9455
9494
|
name,
|
|
@@ -9459,6 +9498,85 @@ function adminAction(options, handler) {
|
|
|
9459
9498
|
};
|
|
9460
9499
|
}
|
|
9461
9500
|
|
|
9501
|
+
// src/admin/multipart.ts
|
|
9502
|
+
var BUSBOY_HINT = "The admin's upload and CSV-import screens need the optional peer `busboy`. Install it with: npm install busboy";
|
|
9503
|
+
async function loadBusboy() {
|
|
9504
|
+
try {
|
|
9505
|
+
const module = await import('busboy');
|
|
9506
|
+
return module.default ?? module;
|
|
9507
|
+
} catch {
|
|
9508
|
+
throw new Error(BUSBOY_HINT);
|
|
9509
|
+
}
|
|
9510
|
+
}
|
|
9511
|
+
var MultipartLimitError = class extends Error {
|
|
9512
|
+
/**
|
|
9513
|
+
* @param message - The operator-facing explanation.
|
|
9514
|
+
*/
|
|
9515
|
+
constructor(message) {
|
|
9516
|
+
super(message);
|
|
9517
|
+
this.name = "MultipartLimitError";
|
|
9518
|
+
}
|
|
9519
|
+
};
|
|
9520
|
+
async function parseMultipart(req, options = {}) {
|
|
9521
|
+
const maxFileBytes = options.maxFileBytes ?? 10 * 1024 * 1024;
|
|
9522
|
+
const maxFiles = options.maxFiles ?? 10;
|
|
9523
|
+
const busboy = await loadBusboy();
|
|
9524
|
+
return await new Promise((resolve, reject) => {
|
|
9525
|
+
const fields = {};
|
|
9526
|
+
const files = [];
|
|
9527
|
+
let settled = false;
|
|
9528
|
+
const fail = (error) => {
|
|
9529
|
+
if (settled) return;
|
|
9530
|
+
settled = true;
|
|
9531
|
+
reject(error);
|
|
9532
|
+
};
|
|
9533
|
+
const parser = busboy({
|
|
9534
|
+
headers: req.headers,
|
|
9535
|
+
limits: { fileSize: maxFileBytes, files: maxFiles }
|
|
9536
|
+
});
|
|
9537
|
+
parser.on("field", ((name, value) => {
|
|
9538
|
+
fields[name] = value;
|
|
9539
|
+
}));
|
|
9540
|
+
parser.on("file", ((name, stream, info) => {
|
|
9541
|
+
const chunks = [];
|
|
9542
|
+
stream.on("data", ((chunk) => chunks.push(chunk)));
|
|
9543
|
+
stream.on("limit", () => {
|
|
9544
|
+
fail(
|
|
9545
|
+
new MultipartLimitError(
|
|
9546
|
+
`The file is larger than the ${Math.floor(maxFileBytes / 1024 / 1024)} MB limit.`
|
|
9547
|
+
)
|
|
9548
|
+
);
|
|
9549
|
+
});
|
|
9550
|
+
stream.on("end", () => {
|
|
9551
|
+
const data = Buffer.concat(chunks);
|
|
9552
|
+
const filename = (info.filename ?? "").split(/[/\\]/).pop() ?? "";
|
|
9553
|
+
if (filename === "" || data.length === 0) return;
|
|
9554
|
+
files.push({
|
|
9555
|
+
field: name,
|
|
9556
|
+
filename,
|
|
9557
|
+
contentType: info.mimeType ?? "application/octet-stream",
|
|
9558
|
+
data
|
|
9559
|
+
});
|
|
9560
|
+
});
|
|
9561
|
+
}));
|
|
9562
|
+
parser.on("filesLimit", () => {
|
|
9563
|
+
fail(new MultipartLimitError(`At most ${maxFiles} files can be uploaded at once.`));
|
|
9564
|
+
});
|
|
9565
|
+
parser.on("error", ((error) => {
|
|
9566
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
9567
|
+
}));
|
|
9568
|
+
parser.on("close", () => {
|
|
9569
|
+
if (settled) return;
|
|
9570
|
+
settled = true;
|
|
9571
|
+
resolve({ fields, files });
|
|
9572
|
+
});
|
|
9573
|
+
req.pipe(parser);
|
|
9574
|
+
});
|
|
9575
|
+
}
|
|
9576
|
+
function isMultipart(req) {
|
|
9577
|
+
return (req.header("content-type") ?? "").toLowerCase().startsWith("multipart/form-data");
|
|
9578
|
+
}
|
|
9579
|
+
|
|
9462
9580
|
// src/admin/columns.ts
|
|
9463
9581
|
function humanizeField(name) {
|
|
9464
9582
|
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(" ");
|
|
@@ -9552,9 +9670,17 @@ var NEVER_EDITABLE = [
|
|
|
9552
9670
|
"id",
|
|
9553
9671
|
"createdAt",
|
|
9554
9672
|
"updatedAt",
|
|
9673
|
+
"createdBy",
|
|
9674
|
+
"updatedBy",
|
|
9555
9675
|
"hashedPassword"
|
|
9556
9676
|
];
|
|
9557
9677
|
var NEVER_LISTED = ["hashedPassword"];
|
|
9678
|
+
var AUDIT_FIELDS = [
|
|
9679
|
+
"createdAt",
|
|
9680
|
+
"updatedAt",
|
|
9681
|
+
"createdBy",
|
|
9682
|
+
"updatedBy"
|
|
9683
|
+
];
|
|
9558
9684
|
var AdminModel = class {
|
|
9559
9685
|
/** The managed model class. */
|
|
9560
9686
|
model;
|
|
@@ -9578,6 +9704,18 @@ var AdminModel = class {
|
|
|
9578
9704
|
canEdit;
|
|
9579
9705
|
/** Whether the delete action is exposed. */
|
|
9580
9706
|
canDelete;
|
|
9707
|
+
/** Audit-log model backing the detail timeline, or `null`. */
|
|
9708
|
+
auditModel;
|
|
9709
|
+
/** Saved list-view presets, in declaration order. */
|
|
9710
|
+
lenses;
|
|
9711
|
+
/** Columns rendered as file inputs. */
|
|
9712
|
+
uploadFields;
|
|
9713
|
+
/** Backend persisting uploaded files, or `null`. */
|
|
9714
|
+
uploadStorage;
|
|
9715
|
+
/** Whether the CSV import page is exposed. */
|
|
9716
|
+
canImport;
|
|
9717
|
+
/** Foreign-key columns rendered as a typed search box. */
|
|
9718
|
+
autocompleteFields;
|
|
9581
9719
|
actions = /* @__PURE__ */ new Map();
|
|
9582
9720
|
slugOverride;
|
|
9583
9721
|
listDisplayOverride;
|
|
@@ -9603,6 +9741,17 @@ var AdminModel = class {
|
|
|
9603
9741
|
this.canCreate = options.canCreate ?? true;
|
|
9604
9742
|
this.canEdit = options.canEdit ?? true;
|
|
9605
9743
|
this.canDelete = options.canDelete ?? true;
|
|
9744
|
+
this.auditModel = options.auditModel ?? null;
|
|
9745
|
+
this.lenses = [...options.lenses ?? []];
|
|
9746
|
+
this.uploadFields = [...options.uploadFields ?? []];
|
|
9747
|
+
this.uploadStorage = options.uploadStorage ?? null;
|
|
9748
|
+
this.canImport = options.canImport ?? false;
|
|
9749
|
+
this.autocompleteFields = [...options.autocompleteFields ?? []];
|
|
9750
|
+
if (this.uploadFields.length > 0 && this.uploadStorage === null) {
|
|
9751
|
+
throw new Error(
|
|
9752
|
+
`AdminModel(${this.model.tablename}).uploadFields requires an uploadStorage (e.g. LocalUploadStorage / S3UploadStorage) \u2014 without one there is nowhere to write the file.`
|
|
9753
|
+
);
|
|
9754
|
+
}
|
|
9606
9755
|
for (const action of options.actions ?? []) {
|
|
9607
9756
|
if (this.actions.has(action.name)) {
|
|
9608
9757
|
throw new Error(
|
|
@@ -9617,7 +9766,9 @@ var AdminModel = class {
|
|
|
9617
9766
|
["listFilter", this.listFilter],
|
|
9618
9767
|
["searchFields", this.searchFields],
|
|
9619
9768
|
["readonlyFields", this.readonlyFields],
|
|
9620
|
-
["identityField", [this.identityField]]
|
|
9769
|
+
["identityField", [this.identityField]],
|
|
9770
|
+
["uploadFields", this.uploadFields],
|
|
9771
|
+
["autocompleteFields", this.autocompleteFields]
|
|
9621
9772
|
]) {
|
|
9622
9773
|
for (const name of names) {
|
|
9623
9774
|
if (!known.has(name)) {
|
|
@@ -9687,6 +9838,25 @@ var AdminModel = class {
|
|
|
9687
9838
|
if (this.listDisplayOverride !== null) return [...this.listDisplayOverride];
|
|
9688
9839
|
return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
|
|
9689
9840
|
}
|
|
9841
|
+
/**
|
|
9842
|
+
* Look a lens up by its slug.
|
|
9843
|
+
*
|
|
9844
|
+
* @param slug - The `?lens=` value.
|
|
9845
|
+
* @returns The lens, or `null` when nothing matches.
|
|
9846
|
+
*/
|
|
9847
|
+
getLens(slug) {
|
|
9848
|
+
return this.lenses.find((lens) => lens.slug === slug) ?? null;
|
|
9849
|
+
}
|
|
9850
|
+
/**
|
|
9851
|
+
* Return the audit/timestamp columns the model actually declares.
|
|
9852
|
+
*
|
|
9853
|
+
* @returns The subset of `createdAt` / `updatedAt` / `createdBy` /
|
|
9854
|
+
* `updatedBy` present on the model, in that order.
|
|
9855
|
+
*/
|
|
9856
|
+
auditFieldNames() {
|
|
9857
|
+
const known = new Set(this.columnNames());
|
|
9858
|
+
return AUDIT_FIELDS.filter((name) => known.has(name));
|
|
9859
|
+
}
|
|
9690
9860
|
/**
|
|
9691
9861
|
* Return the columns the detail view renders.
|
|
9692
9862
|
*
|
|
@@ -9695,10 +9865,16 @@ var AdminModel = class {
|
|
|
9695
9865
|
* where an operator goes to see the whole record, so trimming it there would
|
|
9696
9866
|
* hide data with nowhere else to read it.
|
|
9697
9867
|
*
|
|
9698
|
-
*
|
|
9868
|
+
* The audit/timestamp columns are held back too — they render in the detail
|
|
9869
|
+
* view's own audit panel, next to the change history, rather than scattered
|
|
9870
|
+
* among the domain fields.
|
|
9871
|
+
*
|
|
9872
|
+
* @returns Every domain column, in declaration order.
|
|
9699
9873
|
*/
|
|
9700
9874
|
detailFieldNames() {
|
|
9701
|
-
return this.columnNames().filter(
|
|
9875
|
+
return this.columnNames().filter(
|
|
9876
|
+
(name) => !NEVER_LISTED.includes(name) && !AUDIT_FIELDS.includes(name)
|
|
9877
|
+
);
|
|
9702
9878
|
}
|
|
9703
9879
|
/**
|
|
9704
9880
|
* Return the columns a create/edit form exposes.
|
|
@@ -9774,11 +9950,15 @@ function buildFormFields(admin, options = {}) {
|
|
|
9774
9950
|
const values = options.values ?? {};
|
|
9775
9951
|
const errors = options.errors ?? {};
|
|
9776
9952
|
const foreignKeys = options.foreignKeyOptions ?? {};
|
|
9953
|
+
const autocompleteUrls = options.autocompleteUrls ?? {};
|
|
9954
|
+
const autocompleteLabels = options.autocompleteLabels ?? {};
|
|
9955
|
+
const uploads = new Set(admin.uploadFields);
|
|
9777
9956
|
return admin.editableFieldNames().flatMap((name) => {
|
|
9778
9957
|
const column6 = columns[name];
|
|
9779
9958
|
if (column6 === void 0) return [];
|
|
9780
9959
|
const related = foreignKeys[name];
|
|
9781
|
-
const
|
|
9960
|
+
const autocompleteUrl = autocompleteUrls[name];
|
|
9961
|
+
const spec = uploads.has(name) ? { widget: "file", step: null, options: [] } : autocompleteUrl !== void 0 ? { widget: "autocomplete", step: null, options: [] } : related === void 0 ? widgetForColumn(column6) : { widget: "select", step: null, options: related };
|
|
9782
9962
|
const raw = name in values ? values[name] : literalDefault(column6);
|
|
9783
9963
|
return [
|
|
9784
9964
|
{
|
|
@@ -9790,7 +9970,9 @@ function buildFormFields(admin, options = {}) {
|
|
|
9790
9970
|
checked: spec.widget === "checkbox" && toBoolean(raw),
|
|
9791
9971
|
step: spec.step,
|
|
9792
9972
|
options: spec.options,
|
|
9793
|
-
error: errors[name] ?? null
|
|
9973
|
+
error: errors[name] ?? null,
|
|
9974
|
+
autocompleteUrl: autocompleteUrl ?? null,
|
|
9975
|
+
displayLabel: autocompleteLabels[name] ?? ""
|
|
9794
9976
|
}
|
|
9795
9977
|
];
|
|
9796
9978
|
});
|
|
@@ -9838,13 +10020,15 @@ function coerceValue(column6, widget, raw) {
|
|
|
9838
10020
|
return raw;
|
|
9839
10021
|
}
|
|
9840
10022
|
}
|
|
9841
|
-
function parseFormBody(admin, body) {
|
|
10023
|
+
function parseFormBody(admin, body, options = {}) {
|
|
9842
10024
|
const columns = adminColumns(admin.model);
|
|
9843
10025
|
const data = {};
|
|
9844
10026
|
const errors = {};
|
|
10027
|
+
const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
|
|
9845
10028
|
for (const name of admin.editableFieldNames()) {
|
|
9846
10029
|
const column6 = columns[name];
|
|
9847
10030
|
if (column6 === void 0) continue;
|
|
10031
|
+
if (uploads.has(name)) continue;
|
|
9848
10032
|
const { widget } = widgetForColumn(column6);
|
|
9849
10033
|
if (widget === "checkbox") {
|
|
9850
10034
|
data[name] = toBoolean(body[name]);
|
|
@@ -10155,6 +10339,8 @@ var AdminSite = class {
|
|
|
10155
10339
|
siteUrl;
|
|
10156
10340
|
/** Typed appearance overrides. */
|
|
10157
10341
|
theme;
|
|
10342
|
+
/** Business-metric cards rendered at the top of the dashboard. */
|
|
10343
|
+
dashboardCards;
|
|
10158
10344
|
registry = /* @__PURE__ */ new Map();
|
|
10159
10345
|
/**
|
|
10160
10346
|
* Initialize the site.
|
|
@@ -10167,6 +10353,7 @@ var AdminSite = class {
|
|
|
10167
10353
|
this.indexSubtitle = options.indexSubtitle ?? "Site administration";
|
|
10168
10354
|
this.siteUrl = options.siteUrl ?? null;
|
|
10169
10355
|
this.theme = options.theme ?? {};
|
|
10356
|
+
this.dashboardCards = [...options.dashboardCards ?? []];
|
|
10170
10357
|
}
|
|
10171
10358
|
/**
|
|
10172
10359
|
* Return the centered header brand text.
|
|
@@ -11986,7 +12173,7 @@ function renderMfaPage(context, error) {
|
|
|
11986
12173
|
</section>`;
|
|
11987
12174
|
return renderLayout(context, `Two-factor \xB7 ${context.site.title}`, body);
|
|
11988
12175
|
}
|
|
11989
|
-
function renderDashboardPage(context, cards, metrics) {
|
|
12176
|
+
function renderDashboardPage(context, cards, metrics, businessCards = []) {
|
|
11990
12177
|
const metricsPanel = metrics === null ? "" : `<div class="tempest-admin-stats" aria-label="System metrics">
|
|
11991
12178
|
<div class="tempest-admin-stat">
|
|
11992
12179
|
<span class="tempest-admin-stat__label">CPU</span>
|
|
@@ -11998,6 +12185,7 @@ function renderDashboardPage(context, cards, metrics) {
|
|
|
11998
12185
|
<span class="tempest-admin-stat__sub">${escapeHtml(metrics.memoryUsedGb)} / ${escapeHtml(metrics.memoryTotalGb)} GB</span>
|
|
11999
12186
|
</div>
|
|
12000
12187
|
</div>`;
|
|
12188
|
+
const business = businessCards.length > 0 ? `<div class="tempest-admin-cards" aria-label="Business metrics">${businessCards.map(renderBusinessCard).join("")}</div>` : "";
|
|
12001
12189
|
const models = cards.length > 0 ? `<div class="tempest-admin-models">${cards.map(
|
|
12002
12190
|
(card) => `<article class="tempest-admin-model-card">
|
|
12003
12191
|
<header class="tempest-admin-model-card__head">
|
|
@@ -12014,10 +12202,53 @@ function renderDashboardPage(context, cards, metrics) {
|
|
|
12014
12202
|
<h1>${escapeHtml(context.site.title)}</h1>
|
|
12015
12203
|
<p>${escapeHtml(context.site.indexSubtitle)}</p>
|
|
12016
12204
|
${metricsPanel}
|
|
12205
|
+
${business}
|
|
12017
12206
|
${models}
|
|
12018
12207
|
</section>`;
|
|
12019
12208
|
return renderLayout(context, context.site.title, body);
|
|
12020
12209
|
}
|
|
12210
|
+
function renderBusinessCard(card) {
|
|
12211
|
+
const help = card.helpText !== null ? `<span class="tempest-admin-card__help">${escapeHtml(card.helpText)}</span>` : "";
|
|
12212
|
+
if (card.error !== null) {
|
|
12213
|
+
return `<article class="tempest-admin-card tempest-admin-card--value">
|
|
12214
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12215
|
+
<span class="tempest-admin-card__value">\u2014</span>
|
|
12216
|
+
<span class="tempest-admin-card__help">${escapeHtml(card.error)}</span>
|
|
12217
|
+
</article>`;
|
|
12218
|
+
}
|
|
12219
|
+
const unit = card.unit !== null ? ` <small>${escapeHtml(card.unit)}</small>` : "";
|
|
12220
|
+
if (card.kind === "partition") {
|
|
12221
|
+
const parts = card.segments.map(
|
|
12222
|
+
(segment) => `<li>
|
|
12223
|
+
<span class="tempest-admin-card__part-label">${escapeHtml(segment.label)}</span>
|
|
12224
|
+
<span class="tempest-admin-card__part-bar"><span style="width: ${Math.round(segment.percent)}%"></span></span>
|
|
12225
|
+
<span class="tempest-admin-card__part-value">${escapeHtml(segment.value)}</span>
|
|
12226
|
+
</li>`
|
|
12227
|
+
).join("");
|
|
12228
|
+
return `<article class="tempest-admin-card tempest-admin-card--partition">
|
|
12229
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12230
|
+
<ul class="tempest-admin-card__parts">${parts}</ul>
|
|
12231
|
+
${help}
|
|
12232
|
+
</article>`;
|
|
12233
|
+
}
|
|
12234
|
+
if (card.kind === "trend") {
|
|
12235
|
+
const arrow = card.direction === "up" ? "\u25B2" : card.direction === "down" ? "\u25BC" : "\u25AC";
|
|
12236
|
+
return `<article class="tempest-admin-card tempest-admin-card--trend">
|
|
12237
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12238
|
+
<span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
|
|
12239
|
+
<span class="tempest-admin-card__trend tempest-admin-card__trend--${escapeHtml(card.direction)}">
|
|
12240
|
+
${arrow} ${card.percent === null ? "\u2014" : escapeHtml(card.percent)}
|
|
12241
|
+
<small>vs prev ${escapeHtml(card.previous)}</small>
|
|
12242
|
+
</span>
|
|
12243
|
+
${help}
|
|
12244
|
+
</article>`;
|
|
12245
|
+
}
|
|
12246
|
+
return `<article class="tempest-admin-card tempest-admin-card--value">
|
|
12247
|
+
<span class="tempest-admin-card__label">${escapeHtml(card.label)}</span>
|
|
12248
|
+
<span class="tempest-admin-card__value">${escapeHtml(card.value)}${unit}</span>
|
|
12249
|
+
${help}
|
|
12250
|
+
</article>`;
|
|
12251
|
+
}
|
|
12021
12252
|
function renderFilter(filter) {
|
|
12022
12253
|
const name = `filter_${filter.field}`;
|
|
12023
12254
|
if (filter.kind === "select") {
|
|
@@ -12083,10 +12314,14 @@ function renderListPage(context, view) {
|
|
|
12083
12314
|
</form>
|
|
12084
12315
|
<div class="tempest-admin-list__actions">
|
|
12085
12316
|
${view.newUrl !== null ? `<a class="tempest-admin-list__new" href="${escapeHtml(view.newUrl)}">+ New</a>` : ""}
|
|
12317
|
+
${view.importUrl !== null ? `<a href="${escapeHtml(view.importUrl)}">Import CSV</a>` : ""}
|
|
12086
12318
|
<a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
|
|
12087
12319
|
<a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
|
|
12088
12320
|
</div>
|
|
12089
12321
|
</div>
|
|
12322
|
+
${view.lenses.length > 0 ? `<nav class="tempest-admin-lenses" aria-label="Lenses">${view.lenses.map(
|
|
12323
|
+
(lens) => `<a class="tempest-admin-lens${lens.active ? " tempest-admin-lens--active" : ""}" href="${escapeHtml(lens.url)}">${escapeHtml(lens.label)}</a>`
|
|
12324
|
+
).join("")}</nav>` : ""}
|
|
12090
12325
|
${bulkBar}
|
|
12091
12326
|
<div class="tempest-admin-table-wrap">
|
|
12092
12327
|
<table class="tempest-admin-list__table">
|
|
@@ -12110,6 +12345,7 @@ function renderDetailPage(context, view) {
|
|
|
12110
12345
|
const fields = view.fields.map(
|
|
12111
12346
|
(field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
|
|
12112
12347
|
).join("");
|
|
12348
|
+
const auditPanel = view.audit === null ? "" : renderAuditPanel(view.audit);
|
|
12113
12349
|
const body = `<section class="tempest-admin-detail">
|
|
12114
12350
|
<header class="tempest-admin-detail__header">
|
|
12115
12351
|
<h1>${escapeHtml(view.title)} \xB7 ${escapeHtml(view.identity)}</h1>
|
|
@@ -12123,9 +12359,37 @@ function renderDetailPage(context, view) {
|
|
|
12123
12359
|
</div>
|
|
12124
12360
|
</header>
|
|
12125
12361
|
<dl class="tempest-admin-detail__fields">${fields}</dl>
|
|
12362
|
+
${auditPanel}
|
|
12126
12363
|
</section>`;
|
|
12127
12364
|
return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
|
|
12128
12365
|
}
|
|
12366
|
+
function renderAuditPanel(audit) {
|
|
12367
|
+
const rows = audit.fields.map(
|
|
12368
|
+
(field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
|
|
12369
|
+
).join("");
|
|
12370
|
+
const history = audit.history.length > 0 ? `<ol class="tempest-admin-history">${audit.history.map((entry) => {
|
|
12371
|
+
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(
|
|
12372
|
+
(change) => `<tr><td>${escapeHtml(change.field)}</td><td>${escapeHtml(change.before)}</td><td>${escapeHtml(change.after)}</td></tr>`
|
|
12373
|
+
).join("")}</tbody></table>` : "<p><em>No field changes recorded.</em></p>";
|
|
12374
|
+
const context = entry.context !== null ? `<pre class="tempest-admin-detail__json">${escapeHtml(entry.context)}</pre>` : "";
|
|
12375
|
+
return `<li class="tempest-admin-history__item">
|
|
12376
|
+
<details>
|
|
12377
|
+
<summary>
|
|
12378
|
+
<span class="tempest-admin-history__action">${escapeHtml(entry.action)}</span>
|
|
12379
|
+
<span class="tempest-admin-history__actor">${escapeHtml(entry.actor)}</span>
|
|
12380
|
+
<span class="tempest-admin-history__at">${escapeHtml(entry.at)}</span>
|
|
12381
|
+
</summary>
|
|
12382
|
+
${changes}
|
|
12383
|
+
${context}
|
|
12384
|
+
</details>
|
|
12385
|
+
</li>`;
|
|
12386
|
+
}).join("")}</ol>` : "";
|
|
12387
|
+
return `<section class="tempest-admin-audit">
|
|
12388
|
+
<h2>Audit</h2>
|
|
12389
|
+
<dl class="tempest-admin-detail__fields">${rows}</dl>
|
|
12390
|
+
${history}
|
|
12391
|
+
</section>`;
|
|
12392
|
+
}
|
|
12129
12393
|
function renderFormField(field) {
|
|
12130
12394
|
const required = field.required ? " required" : "";
|
|
12131
12395
|
const name = escapeHtml(field.name);
|
|
@@ -12150,6 +12414,18 @@ function renderFormField(field) {
|
|
|
12150
12414
|
control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
|
|
12151
12415
|
break;
|
|
12152
12416
|
}
|
|
12417
|
+
case "file":
|
|
12418
|
+
control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="file" name="${name}"${required}></label>${field.value === "" ? "" : `<small class="tempest-admin-form__hint">Current: ${value} \u2014 choose a file to replace it.</small>`}`;
|
|
12419
|
+
break;
|
|
12420
|
+
case "autocomplete":
|
|
12421
|
+
control = `<label><span>${label}${field.required ? " *" : ""}</span>
|
|
12422
|
+
<div class="tempest-admin-ac" data-ac data-ac-url="${escapeHtml(field.autocompleteUrl)}">
|
|
12423
|
+
<input type="text" class="tempest-admin-ac__search" value="${escapeHtml(field.displayLabel)}" placeholder="Search\u2026" autocomplete="off" data-ac-search>
|
|
12424
|
+
<input type="hidden" name="${name}" value="${value}"${required} data-ac-value>
|
|
12425
|
+
<ul class="tempest-admin-ac__results" data-ac-results hidden></ul>
|
|
12426
|
+
</div>
|
|
12427
|
+
</label>`;
|
|
12428
|
+
break;
|
|
12153
12429
|
case "number":
|
|
12154
12430
|
control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
|
|
12155
12431
|
break;
|
|
@@ -12177,7 +12453,7 @@ function renderFormPage(context, view) {
|
|
|
12177
12453
|
<a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
|
|
12178
12454
|
</header>
|
|
12179
12455
|
${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
|
|
12180
|
-
<form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form">
|
|
12456
|
+
<form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form"${view.fields.some((field) => field.widget === "file") ? ' enctype="multipart/form-data"' : ""}>
|
|
12181
12457
|
<input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
|
|
12182
12458
|
${view.fields.map(renderFormField).join("")}
|
|
12183
12459
|
<div class="tempest-admin-form__actions">
|
|
@@ -12185,10 +12461,98 @@ function renderFormPage(context, view) {
|
|
|
12185
12461
|
<a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
|
|
12186
12462
|
</div>
|
|
12187
12463
|
</form>
|
|
12464
|
+
${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
|
|
12188
12465
|
</section>`;
|
|
12189
12466
|
return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
|
|
12190
12467
|
}
|
|
12468
|
+
function renderImportPage(context, view) {
|
|
12469
|
+
if (context.session === null) throw new Error("The import page requires a session");
|
|
12470
|
+
const summary = view.created === null ? "" : `<p class="tempest-admin-import__summary">Created ${escapeHtml(view.created)} record${view.created === 1 ? "" : "s"}.</p>`;
|
|
12471
|
+
const failures = view.rowErrors.length > 0 ? `<table class="tempest-admin-import__errors"><thead><tr><th>Row</th><th>Problem</th></tr></thead><tbody>${view.rowErrors.map(
|
|
12472
|
+
(failure) => `<tr><td>${escapeHtml(failure.row)}</td><td>${escapeHtml(failure.message)}</td></tr>`
|
|
12473
|
+
).join("")}</tbody></table>` : "";
|
|
12474
|
+
const body = `<section class="tempest-admin-import">
|
|
12475
|
+
<header class="tempest-admin-detail__header">
|
|
12476
|
+
<h1>Import ${escapeHtml(view.title)}</h1>
|
|
12477
|
+
<a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
|
|
12478
|
+
</header>
|
|
12479
|
+
${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
|
|
12480
|
+
${summary}
|
|
12481
|
+
${failures}
|
|
12482
|
+
<form method="post" action="${escapeHtml(view.actionUrl)}" class="tempest-admin-form__form" enctype="multipart/form-data">
|
|
12483
|
+
<input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
|
|
12484
|
+
<div class="tempest-admin-form__field">
|
|
12485
|
+
<label>
|
|
12486
|
+
<span>CSV file *</span>
|
|
12487
|
+
<input type="file" name="file" accept=".csv,text/csv" required>
|
|
12488
|
+
</label>
|
|
12489
|
+
<small class="tempest-admin-form__hint">
|
|
12490
|
+
UTF-8, comma-separated, with a header row. Recognised columns:
|
|
12491
|
+
<code>${escapeHtml(view.columns.join(", "))}</code>. Unknown columns are ignored.
|
|
12492
|
+
</small>
|
|
12493
|
+
</div>
|
|
12494
|
+
<div class="tempest-admin-form__actions">
|
|
12495
|
+
<button type="submit">Import</button>
|
|
12496
|
+
<a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
|
|
12497
|
+
</div>
|
|
12498
|
+
</form>
|
|
12499
|
+
</section>`;
|
|
12500
|
+
return renderLayout(context, `Import ${view.title} \xB7 ${context.site.title}`, body);
|
|
12501
|
+
}
|
|
12502
|
+
var AUTOCOMPLETE_SCRIPT = `<script>
|
|
12503
|
+
(function () {
|
|
12504
|
+
document.querySelectorAll("[data-ac]").forEach(function (box) {
|
|
12505
|
+
var search = box.querySelector("[data-ac-search]");
|
|
12506
|
+
var value = box.querySelector("[data-ac-value]");
|
|
12507
|
+
var results = box.querySelector("[data-ac-results]");
|
|
12508
|
+
var url = box.getAttribute("data-ac-url");
|
|
12509
|
+
var timer = null;
|
|
12510
|
+
|
|
12511
|
+
function close() {
|
|
12512
|
+
results.hidden = true;
|
|
12513
|
+
results.innerHTML = "";
|
|
12514
|
+
}
|
|
12515
|
+
|
|
12516
|
+
function pick(option) {
|
|
12517
|
+
value.value = option.value;
|
|
12518
|
+
search.value = option.label;
|
|
12519
|
+
close();
|
|
12520
|
+
}
|
|
12521
|
+
|
|
12522
|
+
function run() {
|
|
12523
|
+
var term = search.value.trim();
|
|
12524
|
+
fetch(url + "?q=" + encodeURIComponent(term), { credentials: "same-origin" })
|
|
12525
|
+
.then(function (response) { return response.ok ? response.json() : { options: [] }; })
|
|
12526
|
+
.then(function (payload) {
|
|
12527
|
+
results.innerHTML = "";
|
|
12528
|
+
(payload.options || []).forEach(function (option) {
|
|
12529
|
+
var item = document.createElement("li");
|
|
12530
|
+
item.textContent = option.label;
|
|
12531
|
+
item.setAttribute("role", "option");
|
|
12532
|
+
item.addEventListener("mousedown", function (event) {
|
|
12533
|
+
event.preventDefault();
|
|
12534
|
+
pick(option);
|
|
12535
|
+
});
|
|
12536
|
+
results.appendChild(item);
|
|
12537
|
+
});
|
|
12538
|
+
results.hidden = results.children.length === 0;
|
|
12539
|
+
})
|
|
12540
|
+
.catch(close);
|
|
12541
|
+
}
|
|
12542
|
+
|
|
12543
|
+
search.addEventListener("input", function () {
|
|
12544
|
+
value.value = "";
|
|
12545
|
+
window.clearTimeout(timer);
|
|
12546
|
+
timer = window.setTimeout(run, 250);
|
|
12547
|
+
});
|
|
12548
|
+
search.addEventListener("focus", run);
|
|
12549
|
+
search.addEventListener("blur", function () { window.setTimeout(close, 150); });
|
|
12550
|
+
});
|
|
12551
|
+
})();
|
|
12552
|
+
</script>`;
|
|
12191
12553
|
var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
|
|
12554
|
+
var AUTOCOMPLETE_LIMIT = 20;
|
|
12555
|
+
var AUDIT_HISTORY_LIMIT = 50;
|
|
12192
12556
|
var FK_OPTION_CAP = 1e3;
|
|
12193
12557
|
var FLASH_MAX_LENGTH = 300;
|
|
12194
12558
|
var FLASH_MESSAGES = {
|
|
@@ -12217,6 +12581,7 @@ function makeAdminRouter(site, options) {
|
|
|
12217
12581
|
const theme = resolveAdminTheme(site.theme);
|
|
12218
12582
|
const showMetrics = options.showMetrics ?? true;
|
|
12219
12583
|
const exportMaxRows = options.exportMaxRows ?? 5e3;
|
|
12584
|
+
const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
|
|
12220
12585
|
const sessions = new AdminSessionStore({
|
|
12221
12586
|
secret: options.secretKey,
|
|
12222
12587
|
...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
|
|
@@ -12227,18 +12592,23 @@ function makeAdminRouter(site, options) {
|
|
|
12227
12592
|
const backend = options.authBackend;
|
|
12228
12593
|
const router = express3__default.default.Router();
|
|
12229
12594
|
router.use(prefix, express3__default.default.urlencoded({ extended: false }));
|
|
12230
|
-
const context = (req, session) => ({
|
|
12595
|
+
const context = (req, session, models = site.list()) => ({
|
|
12231
12596
|
site,
|
|
12232
12597
|
theme,
|
|
12233
12598
|
prefix,
|
|
12234
12599
|
session,
|
|
12235
12600
|
currentPath: req.originalUrl.split("?")[0] ?? req.path,
|
|
12236
|
-
navModels:
|
|
12601
|
+
navModels: models.map((admin) => ({
|
|
12237
12602
|
label: admin.verboseNamePlural(),
|
|
12238
12603
|
url: `${prefix}/m/${admin.slug()}`
|
|
12239
12604
|
})),
|
|
12240
12605
|
messages: flashFor(req)
|
|
12241
12606
|
});
|
|
12607
|
+
const allows = async (principal, admin, action) => {
|
|
12608
|
+
if (!flagAllows(admin, action)) return false;
|
|
12609
|
+
if (options.accessPolicy === void 0) return true;
|
|
12610
|
+
return Boolean(await options.accessPolicy(principal, admin, action));
|
|
12611
|
+
};
|
|
12242
12612
|
const flashFor = (req) => {
|
|
12243
12613
|
const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
|
|
12244
12614
|
if (fixed !== void 0) return [fixed];
|
|
@@ -12286,14 +12656,28 @@ function makeAdminRouter(site, options) {
|
|
|
12286
12656
|
res.redirect(`${prefix}/mfa`);
|
|
12287
12657
|
return null;
|
|
12288
12658
|
}
|
|
12289
|
-
|
|
12659
|
+
const visible = [];
|
|
12660
|
+
for (const admin of site.list()) {
|
|
12661
|
+
if (await allows(principal, admin, AdminPermission.VIEW)) visible.push(admin);
|
|
12662
|
+
}
|
|
12663
|
+
return { session, dbSession, principal, visible };
|
|
12290
12664
|
};
|
|
12291
|
-
const resolveAdmin = (req, res, state) => {
|
|
12665
|
+
const resolveAdmin = async (req, res, state, action = AdminPermission.VIEW) => {
|
|
12292
12666
|
const admin = site.get(String(req.params.slug));
|
|
12293
|
-
if (admin === null) {
|
|
12294
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12667
|
+
if (admin === null || !await allows(state.principal, admin, AdminPermission.VIEW)) {
|
|
12668
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12295
12669
|
return null;
|
|
12296
12670
|
}
|
|
12671
|
+
if (action !== AdminPermission.VIEW) {
|
|
12672
|
+
if (!flagAllows(admin, action)) {
|
|
12673
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12674
|
+
return null;
|
|
12675
|
+
}
|
|
12676
|
+
if (!await allows(state.principal, admin, action)) {
|
|
12677
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12678
|
+
return null;
|
|
12679
|
+
}
|
|
12680
|
+
}
|
|
12297
12681
|
return admin;
|
|
12298
12682
|
};
|
|
12299
12683
|
const renderNotFound = (ctx) => renderDashboardPage(
|
|
@@ -12304,7 +12688,7 @@ function makeAdminRouter(site, options) {
|
|
|
12304
12688
|
const checkCsrf = (req, res, state) => {
|
|
12305
12689
|
const body = req.body;
|
|
12306
12690
|
if (csrfTokenMatches(state.session, body?.csrf_token)) return true;
|
|
12307
|
-
html(res, renderNotFound(context(req, state.session)), 403);
|
|
12691
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12308
12692
|
return false;
|
|
12309
12693
|
};
|
|
12310
12694
|
router.get(`${prefix}/static/admin.css`, (_req, res) => {
|
|
@@ -12386,7 +12770,7 @@ function makeAdminRouter(site, options) {
|
|
|
12386
12770
|
const state = await authenticate(req, res);
|
|
12387
12771
|
if (state === null) return;
|
|
12388
12772
|
const cards = [];
|
|
12389
|
-
for (const admin of
|
|
12773
|
+
for (const admin of state.visible) {
|
|
12390
12774
|
let count = null;
|
|
12391
12775
|
try {
|
|
12392
12776
|
count = await admin.repository(state.dbSession).count();
|
|
@@ -12400,9 +12784,13 @@ function makeAdminRouter(site, options) {
|
|
|
12400
12784
|
label: admin.verboseNamePlural(),
|
|
12401
12785
|
count,
|
|
12402
12786
|
url: `${prefix}/m/${admin.slug()}`,
|
|
12403
|
-
newUrl: admin.
|
|
12787
|
+
newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null
|
|
12404
12788
|
});
|
|
12405
12789
|
}
|
|
12790
|
+
const businessCards = [];
|
|
12791
|
+
for (const card of site.dashboardCards) {
|
|
12792
|
+
businessCards.push(await computeBusinessCard(card, state.dbSession));
|
|
12793
|
+
}
|
|
12406
12794
|
let metrics = null;
|
|
12407
12795
|
if (showMetrics) {
|
|
12408
12796
|
const snapshot2 = MetricsUtils.system();
|
|
@@ -12413,7 +12801,15 @@ function makeAdminRouter(site, options) {
|
|
|
12413
12801
|
memoryTotalGb: (snapshot2.memory.total / 1024 ** 3).toFixed(1)
|
|
12414
12802
|
};
|
|
12415
12803
|
}
|
|
12416
|
-
html(
|
|
12804
|
+
html(
|
|
12805
|
+
res,
|
|
12806
|
+
renderDashboardPage(
|
|
12807
|
+
context(req, state.session, state.visible),
|
|
12808
|
+
cards,
|
|
12809
|
+
metrics,
|
|
12810
|
+
businessCards
|
|
12811
|
+
)
|
|
12812
|
+
);
|
|
12417
12813
|
})
|
|
12418
12814
|
);
|
|
12419
12815
|
router.get(
|
|
@@ -12421,7 +12817,7 @@ function makeAdminRouter(site, options) {
|
|
|
12421
12817
|
guarded(async (req, res) => {
|
|
12422
12818
|
const state = await authenticate(req, res);
|
|
12423
12819
|
if (state === null) return;
|
|
12424
|
-
const admin = resolveAdmin(req, res, state);
|
|
12820
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12425
12821
|
if (admin === null) return;
|
|
12426
12822
|
html(res, await renderList(req, admin, state));
|
|
12427
12823
|
})
|
|
@@ -12431,19 +12827,20 @@ function makeAdminRouter(site, options) {
|
|
|
12431
12827
|
guarded(async (req, res) => {
|
|
12432
12828
|
const state = await authenticate(req, res);
|
|
12433
12829
|
if (state === null) return;
|
|
12434
|
-
const admin = resolveAdmin(req, res, state);
|
|
12830
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
|
|
12435
12831
|
if (admin === null) return;
|
|
12436
12832
|
if (!admin.canCreate) {
|
|
12437
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12833
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12438
12834
|
return;
|
|
12439
12835
|
}
|
|
12440
12836
|
html(
|
|
12441
12837
|
res,
|
|
12442
|
-
renderFormPage(context(req, state.session), {
|
|
12838
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12443
12839
|
mode: "create",
|
|
12444
12840
|
title: admin.verboseName(),
|
|
12445
12841
|
fields: buildFormFields(admin, {
|
|
12446
|
-
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
|
|
12842
|
+
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
|
|
12843
|
+
autocompleteUrls: autocompleteUrlsFor(admin)
|
|
12447
12844
|
}),
|
|
12448
12845
|
actionUrl: `${prefix}/m/${admin.slug()}/new`,
|
|
12449
12846
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
@@ -12457,26 +12854,46 @@ function makeAdminRouter(site, options) {
|
|
|
12457
12854
|
guarded(async (req, res) => {
|
|
12458
12855
|
const state = await authenticate(req, res);
|
|
12459
12856
|
if (state === null) return;
|
|
12460
|
-
const admin = resolveAdmin(req, res, state);
|
|
12857
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
|
|
12461
12858
|
if (admin === null) return;
|
|
12462
12859
|
if (!admin.canCreate) {
|
|
12463
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12860
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12861
|
+
return;
|
|
12862
|
+
}
|
|
12863
|
+
let uploadError = null;
|
|
12864
|
+
let submission;
|
|
12865
|
+
try {
|
|
12866
|
+
submission = await readSubmission(req);
|
|
12867
|
+
} catch (error) {
|
|
12868
|
+
if (!(error instanceof MultipartLimitError)) throw error;
|
|
12869
|
+
submission = { fields: {}, files: [] };
|
|
12870
|
+
uploadError = error.message;
|
|
12871
|
+
}
|
|
12872
|
+
const body = submission.fields;
|
|
12873
|
+
if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
|
|
12874
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12464
12875
|
return;
|
|
12465
12876
|
}
|
|
12466
|
-
if (!checkCsrf(req, res, state)) return;
|
|
12467
|
-
const body = req.body;
|
|
12468
12877
|
const parsed = parseFormBody(admin, body);
|
|
12878
|
+
await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
|
|
12469
12879
|
const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
|
|
12880
|
+
const autocompleteLabels = await autocompleteLabelsFor(
|
|
12881
|
+
admin,
|
|
12882
|
+
body,
|
|
12883
|
+
state.dbSession
|
|
12884
|
+
);
|
|
12470
12885
|
const rerender = (error, status) => {
|
|
12471
12886
|
html(
|
|
12472
12887
|
res,
|
|
12473
|
-
renderFormPage(context(req, state.session), {
|
|
12888
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12474
12889
|
mode: "create",
|
|
12475
12890
|
title: admin.verboseName(),
|
|
12476
12891
|
fields: buildFormFields(admin, {
|
|
12477
12892
|
values: body,
|
|
12478
12893
|
errors: parsed.errors,
|
|
12479
|
-
foreignKeyOptions
|
|
12894
|
+
foreignKeyOptions,
|
|
12895
|
+
autocompleteUrls: autocompleteUrlsFor(admin),
|
|
12896
|
+
autocompleteLabels
|
|
12480
12897
|
}),
|
|
12481
12898
|
actionUrl: `${prefix}/m/${admin.slug()}/new`,
|
|
12482
12899
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
@@ -12485,10 +12902,15 @@ function makeAdminRouter(site, options) {
|
|
|
12485
12902
|
status
|
|
12486
12903
|
);
|
|
12487
12904
|
};
|
|
12905
|
+
if (uploadError !== null) {
|
|
12906
|
+
rerender(uploadError, 400);
|
|
12907
|
+
return;
|
|
12908
|
+
}
|
|
12488
12909
|
if (Object.keys(parsed.errors).length > 0) {
|
|
12489
12910
|
rerender("Please fix the highlighted fields.", 400);
|
|
12490
12911
|
return;
|
|
12491
12912
|
}
|
|
12913
|
+
stampActor(admin, parsed.data, backend.principalId(state.principal), true);
|
|
12492
12914
|
try {
|
|
12493
12915
|
await admin.repository(state.dbSession).create(parsed.data);
|
|
12494
12916
|
} catch (error) {
|
|
@@ -12503,11 +12925,11 @@ function makeAdminRouter(site, options) {
|
|
|
12503
12925
|
guarded(async (req, res) => {
|
|
12504
12926
|
const state = await authenticate(req, res);
|
|
12505
12927
|
if (state === null) return;
|
|
12506
|
-
const admin = resolveAdmin(req, res, state);
|
|
12928
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12507
12929
|
if (admin === null) return;
|
|
12508
12930
|
const format = String(req.params.format);
|
|
12509
12931
|
if (format !== "csv" && format !== "json") {
|
|
12510
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
12932
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12511
12933
|
return;
|
|
12512
12934
|
}
|
|
12513
12935
|
const query = await resolveListQuery(req, admin, state.dbSession);
|
|
@@ -12529,7 +12951,7 @@ function makeAdminRouter(site, options) {
|
|
|
12529
12951
|
guarded(async (req, res) => {
|
|
12530
12952
|
const state = await authenticate(req, res);
|
|
12531
12953
|
if (state === null) return;
|
|
12532
|
-
const admin = resolveAdmin(req, res, state);
|
|
12954
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12533
12955
|
if (admin === null) return;
|
|
12534
12956
|
if (!checkCsrf(req, res, state)) return;
|
|
12535
12957
|
const body = req.body;
|
|
@@ -12545,7 +12967,12 @@ function makeAdminRouter(site, options) {
|
|
|
12545
12967
|
return;
|
|
12546
12968
|
}
|
|
12547
12969
|
if (!bulkActionsFor(admin).some((option) => option.value === action)) {
|
|
12548
|
-
html(res, renderNotFound(context(req, state.session)), 400);
|
|
12970
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 400);
|
|
12971
|
+
return;
|
|
12972
|
+
}
|
|
12973
|
+
const needed = action === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
|
|
12974
|
+
if (!await allows(state.principal, admin, needed)) {
|
|
12975
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12549
12976
|
return;
|
|
12550
12977
|
}
|
|
12551
12978
|
const repository = admin.repository(state.dbSession);
|
|
@@ -12553,7 +12980,7 @@ function makeAdminRouter(site, options) {
|
|
|
12553
12980
|
if (action.startsWith("custom:")) {
|
|
12554
12981
|
const custom = admin.getAction(action.slice("custom:".length));
|
|
12555
12982
|
if (custom === null) {
|
|
12556
|
-
html(res, renderNotFound(context(req, state.session)), 400);
|
|
12983
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 400);
|
|
12557
12984
|
return;
|
|
12558
12985
|
}
|
|
12559
12986
|
let result;
|
|
@@ -12598,28 +13025,152 @@ function makeAdminRouter(site, options) {
|
|
|
12598
13025
|
);
|
|
12599
13026
|
})
|
|
12600
13027
|
);
|
|
13028
|
+
router.get(
|
|
13029
|
+
`${prefix}/m/:slug/import`,
|
|
13030
|
+
guarded(async (req, res) => {
|
|
13031
|
+
const state = await authenticate(req, res);
|
|
13032
|
+
if (state === null) return;
|
|
13033
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
|
|
13034
|
+
if (admin === null) return;
|
|
13035
|
+
if (!admin.canImport) {
|
|
13036
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
13037
|
+
return;
|
|
13038
|
+
}
|
|
13039
|
+
html(res, renderImport(req, state, admin, null, null, []));
|
|
13040
|
+
})
|
|
13041
|
+
);
|
|
13042
|
+
router.post(
|
|
13043
|
+
`${prefix}/m/:slug/import`,
|
|
13044
|
+
guarded(async (req, res) => {
|
|
13045
|
+
const state = await authenticate(req, res);
|
|
13046
|
+
if (state === null) return;
|
|
13047
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
|
|
13048
|
+
if (admin === null) return;
|
|
13049
|
+
if (!admin.canImport) {
|
|
13050
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
13051
|
+
return;
|
|
13052
|
+
}
|
|
13053
|
+
let submission;
|
|
13054
|
+
try {
|
|
13055
|
+
submission = await readSubmission(req);
|
|
13056
|
+
} catch (error) {
|
|
13057
|
+
if (!(error instanceof MultipartLimitError)) throw error;
|
|
13058
|
+
html(res, renderImport(req, state, admin, error.message, null, []), 400);
|
|
13059
|
+
return;
|
|
13060
|
+
}
|
|
13061
|
+
if (!csrfTokenMatches(state.session, submission.fields.csrf_token)) {
|
|
13062
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
13063
|
+
return;
|
|
13064
|
+
}
|
|
13065
|
+
const file = submission.files[0];
|
|
13066
|
+
if (file === void 0) {
|
|
13067
|
+
html(
|
|
13068
|
+
res,
|
|
13069
|
+
renderImport(req, state, admin, "Choose a CSV file to import.", null, []),
|
|
13070
|
+
400
|
|
13071
|
+
);
|
|
13072
|
+
return;
|
|
13073
|
+
}
|
|
13074
|
+
let rows;
|
|
13075
|
+
try {
|
|
13076
|
+
rows = parseCsv(file.data.toString("utf8"));
|
|
13077
|
+
} catch (error) {
|
|
13078
|
+
const message = error instanceof Error ? error.message : "Could not read the file as UTF-8 CSV.";
|
|
13079
|
+
html(res, renderImport(req, state, admin, message, null, []), 400);
|
|
13080
|
+
return;
|
|
13081
|
+
}
|
|
13082
|
+
const repository = admin.repository(state.dbSession);
|
|
13083
|
+
const actorId = backend.principalId(state.principal);
|
|
13084
|
+
const rowErrors = [];
|
|
13085
|
+
let created = 0;
|
|
13086
|
+
for (const [index, row] of rows.entries()) {
|
|
13087
|
+
const parsed = parseFormBody(admin, row, { uploadsAsText: true });
|
|
13088
|
+
const failures = Object.entries(parsed.errors);
|
|
13089
|
+
if (failures.length > 0) {
|
|
13090
|
+
rowErrors.push({
|
|
13091
|
+
row: index + 2,
|
|
13092
|
+
message: failures.map(([field, error]) => `${field}: ${error}`).join("; ")
|
|
13093
|
+
});
|
|
13094
|
+
continue;
|
|
13095
|
+
}
|
|
13096
|
+
stampActor(admin, parsed.data, actorId, true);
|
|
13097
|
+
try {
|
|
13098
|
+
await repository.create(parsed.data);
|
|
13099
|
+
created += 1;
|
|
13100
|
+
} catch (error) {
|
|
13101
|
+
rowErrors.push({
|
|
13102
|
+
row: index + 2,
|
|
13103
|
+
message: error instanceof Error ? error.message : String(error)
|
|
13104
|
+
});
|
|
13105
|
+
}
|
|
13106
|
+
}
|
|
13107
|
+
html(res, renderImport(req, state, admin, null, created, rowErrors));
|
|
13108
|
+
})
|
|
13109
|
+
);
|
|
13110
|
+
router.get(
|
|
13111
|
+
`${prefix}/m/:slug/autocomplete/:field`,
|
|
13112
|
+
guarded(async (req, res) => {
|
|
13113
|
+
if (sessions.load(req) === null) {
|
|
13114
|
+
res.status(401).json({ options: [] });
|
|
13115
|
+
return;
|
|
13116
|
+
}
|
|
13117
|
+
const state = await authenticate(req, res);
|
|
13118
|
+
if (state === null) return;
|
|
13119
|
+
const admin = site.get(String(req.params.slug));
|
|
13120
|
+
const field = String(req.params.field);
|
|
13121
|
+
if (admin === null || !admin.autocompleteFields.includes(field) || !await allows(state.principal, admin, AdminPermission.VIEW)) {
|
|
13122
|
+
res.status(404).json({ options: [] });
|
|
13123
|
+
return;
|
|
13124
|
+
}
|
|
13125
|
+
const column6 = adminColumns(admin.model)[field];
|
|
13126
|
+
const table = column6 === void 0 ? null : foreignKeyTable(column6);
|
|
13127
|
+
const referenced = table === null ? null : site.get(table);
|
|
13128
|
+
if (referenced === null) {
|
|
13129
|
+
res.json({ options: [] });
|
|
13130
|
+
return;
|
|
13131
|
+
}
|
|
13132
|
+
const term = queryString(req.query.q);
|
|
13133
|
+
const searchable = referenced.searchFields.filter((name) => {
|
|
13134
|
+
const target = adminColumns(referenced.model)[name];
|
|
13135
|
+
return target !== void 0 && isSearchableColumn(target);
|
|
13136
|
+
});
|
|
13137
|
+
const filters = term === "" || searchable.length === 0 ? void 0 : tempestDbJs.or(...searchable.map((name) => ({ [name]: { ilike: `%${term}%` } })));
|
|
13138
|
+
const page2 = await referenced.repository(state.dbSession).paginate({
|
|
13139
|
+
page: 1,
|
|
13140
|
+
pageSize: AUTOCOMPLETE_LIMIT,
|
|
13141
|
+
...filters === void 0 ? {} : { filters }
|
|
13142
|
+
});
|
|
13143
|
+
res.json({
|
|
13144
|
+
options: page2.items.map((row) => ({
|
|
13145
|
+
value: String(row[referenced.identityField]),
|
|
13146
|
+
label: foreignKeyLabel(referenced, row)
|
|
13147
|
+
}))
|
|
13148
|
+
});
|
|
13149
|
+
})
|
|
13150
|
+
);
|
|
12601
13151
|
router.get(
|
|
12602
13152
|
`${prefix}/m/:slug/:identity`,
|
|
12603
13153
|
guarded(async (req, res) => {
|
|
12604
13154
|
const state = await authenticate(req, res);
|
|
12605
13155
|
if (state === null) return;
|
|
12606
|
-
const admin = resolveAdmin(req, res, state);
|
|
13156
|
+
const admin = await resolveAdmin(req, res, state);
|
|
12607
13157
|
if (admin === null) return;
|
|
12608
13158
|
const row = await findRow(admin, state.dbSession, String(req.params.identity));
|
|
12609
13159
|
if (row === null) {
|
|
12610
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
13160
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12611
13161
|
return;
|
|
12612
13162
|
}
|
|
12613
13163
|
const identity = String(row[admin.identityField]);
|
|
12614
13164
|
html(
|
|
12615
13165
|
res,
|
|
12616
|
-
renderDetailPage(context(req, state.session), {
|
|
13166
|
+
renderDetailPage(context(req, state.session, state.visible), {
|
|
12617
13167
|
title: admin.verboseName(),
|
|
12618
13168
|
identity,
|
|
13169
|
+
audit: await buildAuditView(admin, row, state.dbSession),
|
|
12619
13170
|
fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
|
|
12620
13171
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
12621
|
-
editUrl: admin.
|
|
12622
|
-
deleteUrl: admin.
|
|
13172
|
+
editUrl: await allows(state.principal, admin, AdminPermission.EDIT) ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
|
|
13173
|
+
deleteUrl: await allows(state.principal, admin, AdminPermission.DELETE) ? `${prefix}/m/${admin.slug()}/${identity}/delete` : null
|
|
12623
13174
|
})
|
|
12624
13175
|
);
|
|
12625
13176
|
})
|
|
@@ -12629,22 +13180,24 @@ function makeAdminRouter(site, options) {
|
|
|
12629
13180
|
guarded(async (req, res) => {
|
|
12630
13181
|
const state = await authenticate(req, res);
|
|
12631
13182
|
if (state === null) return;
|
|
12632
|
-
const admin = resolveAdmin(req, res, state);
|
|
13183
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
|
|
12633
13184
|
if (admin === null) return;
|
|
12634
13185
|
const identity = String(req.params.identity);
|
|
12635
13186
|
const row = admin.canEdit ? await findRow(admin, state.dbSession, identity) : null;
|
|
12636
13187
|
if (row === null) {
|
|
12637
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
13188
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12638
13189
|
return;
|
|
12639
13190
|
}
|
|
12640
13191
|
html(
|
|
12641
13192
|
res,
|
|
12642
|
-
renderFormPage(context(req, state.session), {
|
|
13193
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12643
13194
|
mode: "edit",
|
|
12644
13195
|
title: admin.verboseName(),
|
|
12645
13196
|
fields: buildFormFields(admin, {
|
|
12646
13197
|
values: row,
|
|
12647
|
-
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
|
|
13198
|
+
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
|
|
13199
|
+
autocompleteUrls: autocompleteUrlsFor(admin),
|
|
13200
|
+
autocompleteLabels: await autocompleteLabelsFor(admin, row, state.dbSession)
|
|
12648
13201
|
}),
|
|
12649
13202
|
actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
|
|
12650
13203
|
backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
|
|
@@ -12658,27 +13211,47 @@ function makeAdminRouter(site, options) {
|
|
|
12658
13211
|
guarded(async (req, res) => {
|
|
12659
13212
|
const state = await authenticate(req, res);
|
|
12660
13213
|
if (state === null) return;
|
|
12661
|
-
const admin = resolveAdmin(req, res, state);
|
|
13214
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
|
|
12662
13215
|
if (admin === null) return;
|
|
12663
13216
|
const identity = String(req.params.identity);
|
|
12664
13217
|
if (!admin.canEdit) {
|
|
12665
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
13218
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
13219
|
+
return;
|
|
13220
|
+
}
|
|
13221
|
+
let uploadError = null;
|
|
13222
|
+
let submission;
|
|
13223
|
+
try {
|
|
13224
|
+
submission = await readSubmission(req);
|
|
13225
|
+
} catch (error) {
|
|
13226
|
+
if (!(error instanceof MultipartLimitError)) throw error;
|
|
13227
|
+
submission = { fields: {}, files: [] };
|
|
13228
|
+
uploadError = error.message;
|
|
13229
|
+
}
|
|
13230
|
+
const body = submission.fields;
|
|
13231
|
+
if (uploadError === null && !csrfTokenMatches(state.session, body.csrf_token)) {
|
|
13232
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 403);
|
|
12666
13233
|
return;
|
|
12667
13234
|
}
|
|
12668
|
-
if (!checkCsrf(req, res, state)) return;
|
|
12669
|
-
const body = req.body;
|
|
12670
13235
|
const parsed = parseFormBody(admin, body);
|
|
13236
|
+
await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
|
|
12671
13237
|
const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
|
|
13238
|
+
const autocompleteLabels = await autocompleteLabelsFor(
|
|
13239
|
+
admin,
|
|
13240
|
+
body,
|
|
13241
|
+
state.dbSession
|
|
13242
|
+
);
|
|
12672
13243
|
const rerender = (error, status) => {
|
|
12673
13244
|
html(
|
|
12674
13245
|
res,
|
|
12675
|
-
renderFormPage(context(req, state.session), {
|
|
13246
|
+
renderFormPage(context(req, state.session, state.visible), {
|
|
12676
13247
|
mode: "edit",
|
|
12677
13248
|
title: admin.verboseName(),
|
|
12678
13249
|
fields: buildFormFields(admin, {
|
|
12679
13250
|
values: body,
|
|
12680
13251
|
errors: parsed.errors,
|
|
12681
|
-
foreignKeyOptions
|
|
13252
|
+
foreignKeyOptions,
|
|
13253
|
+
autocompleteUrls: autocompleteUrlsFor(admin),
|
|
13254
|
+
autocompleteLabels
|
|
12682
13255
|
}),
|
|
12683
13256
|
actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
|
|
12684
13257
|
backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
|
|
@@ -12687,14 +13260,24 @@ function makeAdminRouter(site, options) {
|
|
|
12687
13260
|
status
|
|
12688
13261
|
);
|
|
12689
13262
|
};
|
|
13263
|
+
if (uploadError !== null) {
|
|
13264
|
+
rerender(uploadError, 400);
|
|
13265
|
+
return;
|
|
13266
|
+
}
|
|
12690
13267
|
if (Object.keys(parsed.errors).length > 0) {
|
|
12691
13268
|
rerender("Please fix the highlighted fields.", 400);
|
|
12692
13269
|
return;
|
|
12693
13270
|
}
|
|
13271
|
+
stampActor(
|
|
13272
|
+
admin,
|
|
13273
|
+
parsed.data,
|
|
13274
|
+
backend.principalId(state.principal),
|
|
13275
|
+
false
|
|
13276
|
+
);
|
|
12694
13277
|
try {
|
|
12695
13278
|
const changed = await admin.repository(state.dbSession).update({ [admin.identityField]: identity }, parsed.data);
|
|
12696
13279
|
if (changed === 0) {
|
|
12697
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
13280
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12698
13281
|
return;
|
|
12699
13282
|
}
|
|
12700
13283
|
} catch (error) {
|
|
@@ -12709,10 +13292,10 @@ function makeAdminRouter(site, options) {
|
|
|
12709
13292
|
guarded(async (req, res) => {
|
|
12710
13293
|
const state = await authenticate(req, res);
|
|
12711
13294
|
if (state === null) return;
|
|
12712
|
-
const admin = resolveAdmin(req, res, state);
|
|
13295
|
+
const admin = await resolveAdmin(req, res, state, AdminPermission.DELETE);
|
|
12713
13296
|
if (admin === null) return;
|
|
12714
13297
|
if (!admin.canDelete) {
|
|
12715
|
-
html(res, renderNotFound(context(req, state.session)), 404);
|
|
13298
|
+
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12716
13299
|
return;
|
|
12717
13300
|
}
|
|
12718
13301
|
if (!checkCsrf(req, res, state)) return;
|
|
@@ -12720,10 +13303,96 @@ function makeAdminRouter(site, options) {
|
|
|
12720
13303
|
res.redirect(`${prefix}/m/${admin.slug()}?ok=deleted`);
|
|
12721
13304
|
})
|
|
12722
13305
|
);
|
|
13306
|
+
async function actorLabel(actor, dbSession) {
|
|
13307
|
+
if (actor === null || actor === void 0 || actor === "") return "";
|
|
13308
|
+
const principal = await backend.loadPrincipal(dbSession, String(actor));
|
|
13309
|
+
return principal === null ? String(actor) : backend.displayName(principal);
|
|
13310
|
+
}
|
|
13311
|
+
async function buildAuditView(admin, row, dbSession) {
|
|
13312
|
+
const fields = [];
|
|
13313
|
+
for (const name of admin.auditFieldNames()) {
|
|
13314
|
+
const value = name === "createdBy" || name === "updatedBy" ? await actorLabel(row[name], dbSession) : formatCellValue(row[name]);
|
|
13315
|
+
fields.push({ label: humanizeField(name), value });
|
|
13316
|
+
}
|
|
13317
|
+
const history = [];
|
|
13318
|
+
if (admin.auditModel !== null) {
|
|
13319
|
+
const entity = admin.model.name;
|
|
13320
|
+
const entityId = String(row[admin.identityField]);
|
|
13321
|
+
const page2 = await new tempestDbJs.BaseRepository(admin.auditModel, dbSession).paginate({
|
|
13322
|
+
page: 1,
|
|
13323
|
+
pageSize: AUDIT_HISTORY_LIMIT,
|
|
13324
|
+
orderBy: "createdAt",
|
|
13325
|
+
ascending: false,
|
|
13326
|
+
filters: { entity, entityId }
|
|
13327
|
+
});
|
|
13328
|
+
for (const entry of page2.items) {
|
|
13329
|
+
const changes = entry.changes ?? {};
|
|
13330
|
+
history.push({
|
|
13331
|
+
action: String(entry.action ?? ""),
|
|
13332
|
+
at: formatCellValue(entry.createdAt),
|
|
13333
|
+
actor: await actorLabel(entry.actor, dbSession) || "\u2014",
|
|
13334
|
+
changes: Object.entries(changes).map(([field, change]) => ({
|
|
13335
|
+
field,
|
|
13336
|
+
before: formatCellValue(change?.before),
|
|
13337
|
+
after: formatCellValue(change?.after)
|
|
13338
|
+
})),
|
|
13339
|
+
context: entry.context === null || entry.context === void 0 ? null : JSON.stringify(entry.context, null, 2)
|
|
13340
|
+
});
|
|
13341
|
+
}
|
|
13342
|
+
}
|
|
13343
|
+
if (fields.length === 0 && history.length === 0) return null;
|
|
13344
|
+
return { fields, history };
|
|
13345
|
+
}
|
|
12723
13346
|
async function findRow(admin, dbSession, identity) {
|
|
12724
13347
|
const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
|
|
12725
13348
|
return row ?? null;
|
|
12726
13349
|
}
|
|
13350
|
+
function renderImport(req, state, admin, error, created, rowErrors) {
|
|
13351
|
+
return renderImportPage(context(req, state.session, state.visible), {
|
|
13352
|
+
title: admin.verboseNamePlural(),
|
|
13353
|
+
actionUrl: `${prefix}/m/${admin.slug()}/import`,
|
|
13354
|
+
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
13355
|
+
columns: admin.editableFieldNames(),
|
|
13356
|
+
error,
|
|
13357
|
+
created,
|
|
13358
|
+
rowErrors
|
|
13359
|
+
});
|
|
13360
|
+
}
|
|
13361
|
+
async function readSubmission(req) {
|
|
13362
|
+
if (!isMultipart(req)) {
|
|
13363
|
+
return { fields: req.body ?? {}, files: [] };
|
|
13364
|
+
}
|
|
13365
|
+
const parsed = await parseMultipart(req, { maxFileBytes: maxUploadBytes });
|
|
13366
|
+
return { fields: parsed.fields, files: parsed.files };
|
|
13367
|
+
}
|
|
13368
|
+
async function applyUploads(admin, data, errors, files, creating) {
|
|
13369
|
+
if (admin.uploadFields.length === 0) return;
|
|
13370
|
+
const storage2 = admin.uploadStorage;
|
|
13371
|
+
if (storage2 === null) return;
|
|
13372
|
+
const columns = adminColumns(admin.model);
|
|
13373
|
+
for (const field of admin.uploadFields) {
|
|
13374
|
+
const file = files.find((candidate) => candidate.field === field);
|
|
13375
|
+
if (file === void 0) {
|
|
13376
|
+
const column6 = columns[field];
|
|
13377
|
+
if (creating && column6 !== void 0 && !isColumnOptional(column6)) {
|
|
13378
|
+
errors[field] = "This field is required.";
|
|
13379
|
+
}
|
|
13380
|
+
continue;
|
|
13381
|
+
}
|
|
13382
|
+
const extension = file.filename.includes(".") ? `.${file.filename.split(".").pop()}` : "";
|
|
13383
|
+
const key = `${admin.slug()}/${field}/${crypto.randomUUID()}${extension}`;
|
|
13384
|
+
const saved = await storage2.save(key, file.data, { contentType: file.contentType });
|
|
13385
|
+
data[field] = saved.key;
|
|
13386
|
+
}
|
|
13387
|
+
}
|
|
13388
|
+
async function permittedBulkActions(admin, principal) {
|
|
13389
|
+
const permitted = [];
|
|
13390
|
+
for (const option of bulkActionsFor(admin)) {
|
|
13391
|
+
const needed = option.value === "delete" ? AdminPermission.DELETE : AdminPermission.EDIT;
|
|
13392
|
+
if (await allows(principal, admin, needed)) permitted.push(option);
|
|
13393
|
+
}
|
|
13394
|
+
return permitted;
|
|
13395
|
+
}
|
|
12727
13396
|
async function renderList(req, admin, state) {
|
|
12728
13397
|
const columns = adminColumns(admin.model);
|
|
12729
13398
|
const query = await resolveListQuery(req, admin, state.dbSession);
|
|
@@ -12778,21 +13447,37 @@ function makeAdminRouter(site, options) {
|
|
|
12778
13447
|
searchValue: query.search,
|
|
12779
13448
|
filters: query.filterViews,
|
|
12780
13449
|
sort,
|
|
12781
|
-
newUrl: admin.
|
|
12782
|
-
|
|
13450
|
+
newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
|
|
13451
|
+
importUrl: admin.canImport && await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/import` : null,
|
|
13452
|
+
bulkActions: await permittedBulkActions(admin, state.principal),
|
|
12783
13453
|
bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
|
|
12784
13454
|
exportCsvUrl: exportUrl("csv"),
|
|
12785
|
-
exportJsonUrl: exportUrl("json")
|
|
13455
|
+
exportJsonUrl: exportUrl("json"),
|
|
13456
|
+
lenses: admin.lenses.length === 0 ? [] : [
|
|
13457
|
+
{
|
|
13458
|
+
label: "All",
|
|
13459
|
+
url: `?${buildQuery({ ...query.baseQuery, lens: void 0 })}`,
|
|
13460
|
+
active: query.lens === ""
|
|
13461
|
+
},
|
|
13462
|
+
...admin.lenses.map((entry) => ({
|
|
13463
|
+
label: entry.label,
|
|
13464
|
+
url: `?${buildQuery({ ...query.baseQuery, lens: entry.slug })}`,
|
|
13465
|
+
active: query.lens === entry.slug
|
|
13466
|
+
}))
|
|
13467
|
+
]
|
|
12786
13468
|
};
|
|
12787
|
-
return renderListPage(context(req, state.session), view);
|
|
13469
|
+
return renderListPage(context(req, state.session, state.visible), view);
|
|
12788
13470
|
}
|
|
12789
13471
|
async function resolveListQuery(req, admin, dbSession) {
|
|
12790
13472
|
const columns = adminColumns(admin.model);
|
|
12791
13473
|
const search = queryString(req.query.q);
|
|
12792
13474
|
const sortField = queryString(req.query.sort);
|
|
12793
13475
|
const sortColumn = sortField in columns ? sortField : null;
|
|
12794
|
-
const
|
|
13476
|
+
const lens = admin.getLens(queryString(req.query.lens));
|
|
12795
13477
|
const conditions = [];
|
|
13478
|
+
if (lens !== null && Object.keys(lens.filters).length > 0) {
|
|
13479
|
+
conditions.push(lens.filters);
|
|
13480
|
+
}
|
|
12796
13481
|
const filterViews = [];
|
|
12797
13482
|
for (const field of admin.listFilter) {
|
|
12798
13483
|
const column6 = columns[field];
|
|
@@ -12856,15 +13541,21 @@ function makeAdminRouter(site, options) {
|
|
|
12856
13541
|
baseQuery[`filter_${view.field}`] = view.value;
|
|
12857
13542
|
}
|
|
12858
13543
|
}
|
|
13544
|
+
const lensOrder = lens?.orderBy ?? null;
|
|
13545
|
+
const lensDescending = lensOrder?.startsWith("-");
|
|
13546
|
+
const lensColumn = lensOrder === null ? null : lensOrder.replace(/^-/, "");
|
|
13547
|
+
const ascending = sortColumn !== null ? queryString(req.query.dir) !== "desc" : lensColumn !== null ? !lensDescending : admin.orderAscending;
|
|
13548
|
+
if (lens !== null) baseQuery.lens = lens.slug;
|
|
12859
13549
|
return {
|
|
12860
13550
|
search,
|
|
12861
13551
|
searchable,
|
|
12862
13552
|
where: conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions),
|
|
12863
|
-
orderBy: sortColumn ?? admin.orderKey ?? void 0,
|
|
13553
|
+
orderBy: sortColumn ?? lensColumn ?? admin.orderKey ?? void 0,
|
|
12864
13554
|
ascending,
|
|
12865
13555
|
sortColumn,
|
|
12866
13556
|
filterViews,
|
|
12867
|
-
baseQuery
|
|
13557
|
+
baseQuery,
|
|
13558
|
+
lens: lens?.slug ?? ""
|
|
12868
13559
|
};
|
|
12869
13560
|
}
|
|
12870
13561
|
async function relatedOptions(column6, dbSession) {
|
|
@@ -12882,6 +13573,7 @@ function makeAdminRouter(site, options) {
|
|
|
12882
13573
|
const columns = adminColumns(admin.model);
|
|
12883
13574
|
const options2 = {};
|
|
12884
13575
|
for (const field of Object.keys(foreignKeyFields(admin))) {
|
|
13576
|
+
if (admin.autocompleteFields.includes(field)) continue;
|
|
12885
13577
|
const column6 = columns[field];
|
|
12886
13578
|
if (column6 === void 0) continue;
|
|
12887
13579
|
const related = await relatedOptions(column6, dbSession);
|
|
@@ -12889,8 +13581,101 @@ function makeAdminRouter(site, options) {
|
|
|
12889
13581
|
}
|
|
12890
13582
|
return options2;
|
|
12891
13583
|
}
|
|
13584
|
+
function autocompleteUrlsFor(admin) {
|
|
13585
|
+
const urls = {};
|
|
13586
|
+
for (const field of admin.autocompleteFields) {
|
|
13587
|
+
urls[field] = `${prefix}/m/${admin.slug()}/autocomplete/${field}`;
|
|
13588
|
+
}
|
|
13589
|
+
return urls;
|
|
13590
|
+
}
|
|
13591
|
+
async function autocompleteLabelsFor(admin, row, dbSession) {
|
|
13592
|
+
const labels = {};
|
|
13593
|
+
if (row === null) return labels;
|
|
13594
|
+
const columns = adminColumns(admin.model);
|
|
13595
|
+
for (const field of admin.autocompleteFields) {
|
|
13596
|
+
const value = row[field];
|
|
13597
|
+
if (value === null || value === void 0 || value === "") continue;
|
|
13598
|
+
const column6 = columns[field];
|
|
13599
|
+
const table = column6 === void 0 ? null : foreignKeyTable(column6);
|
|
13600
|
+
const referenced = table === null ? null : site.get(table);
|
|
13601
|
+
if (referenced === null) continue;
|
|
13602
|
+
const related = await referenced.repository(dbSession).first({ [referenced.identityField]: value });
|
|
13603
|
+
if (related !== null) labels[field] = foreignKeyLabel(referenced, related);
|
|
13604
|
+
}
|
|
13605
|
+
return labels;
|
|
13606
|
+
}
|
|
12892
13607
|
return router;
|
|
12893
13608
|
}
|
|
13609
|
+
function flagAllows(admin, action) {
|
|
13610
|
+
if (action === AdminPermission.CREATE) return admin.canCreate;
|
|
13611
|
+
if (action === AdminPermission.EDIT) return admin.canEdit;
|
|
13612
|
+
if (action === AdminPermission.DELETE) return admin.canDelete;
|
|
13613
|
+
return true;
|
|
13614
|
+
}
|
|
13615
|
+
function stampActor(admin, data, actorId, creating) {
|
|
13616
|
+
const columns = adminColumns(admin.model);
|
|
13617
|
+
if (creating && "createdBy" in columns) data.createdBy = actorId;
|
|
13618
|
+
if ("updatedBy" in columns) data.updatedBy = actorId;
|
|
13619
|
+
}
|
|
13620
|
+
function formatMetric(value) {
|
|
13621
|
+
if (typeof value === "string") return value;
|
|
13622
|
+
return Number.isInteger(value) ? String(value) : value.toFixed(2);
|
|
13623
|
+
}
|
|
13624
|
+
async function computeBusinessCard(card, dbSession) {
|
|
13625
|
+
const base = {
|
|
13626
|
+
label: card.label,
|
|
13627
|
+
unit: null,
|
|
13628
|
+
direction: "flat",
|
|
13629
|
+
percent: null,
|
|
13630
|
+
previous: "",
|
|
13631
|
+
segments: [],
|
|
13632
|
+
helpText: card.helpText ?? null
|
|
13633
|
+
};
|
|
13634
|
+
let data;
|
|
13635
|
+
try {
|
|
13636
|
+
data = await card.compute(dbSession);
|
|
13637
|
+
} catch (error) {
|
|
13638
|
+
logger2.warning("Admin dashboard card failed", {
|
|
13639
|
+
card: card.label,
|
|
13640
|
+
error: error instanceof Error ? error.message : String(error)
|
|
13641
|
+
});
|
|
13642
|
+
return { ...base, kind: "value", value: "", error: "Could not compute this metric." };
|
|
13643
|
+
}
|
|
13644
|
+
if (data.kind === "partition") {
|
|
13645
|
+
const total = partitionTotal(data);
|
|
13646
|
+
return {
|
|
13647
|
+
...base,
|
|
13648
|
+
kind: "partition",
|
|
13649
|
+
value: formatMetric(total),
|
|
13650
|
+
segments: data.segments.map((segment) => ({
|
|
13651
|
+
label: segment.label,
|
|
13652
|
+
value: formatMetric(segment.value),
|
|
13653
|
+
percent: total === 0 ? 0 : segment.value / total * 100
|
|
13654
|
+
})),
|
|
13655
|
+
error: null
|
|
13656
|
+
};
|
|
13657
|
+
}
|
|
13658
|
+
if (data.kind === "trend") {
|
|
13659
|
+
const percent = trendPercent(data);
|
|
13660
|
+
return {
|
|
13661
|
+
...base,
|
|
13662
|
+
kind: "trend",
|
|
13663
|
+
value: formatMetric(data.value),
|
|
13664
|
+
unit: data.unit ?? null,
|
|
13665
|
+
direction: trendDirection(data),
|
|
13666
|
+
percent: percent === null ? null : `${percent >= 0 ? "+" : ""}${percent.toFixed(1)}%`,
|
|
13667
|
+
previous: formatMetric(data.previous),
|
|
13668
|
+
error: null
|
|
13669
|
+
};
|
|
13670
|
+
}
|
|
13671
|
+
return {
|
|
13672
|
+
...base,
|
|
13673
|
+
kind: "value",
|
|
13674
|
+
value: formatMetric(data.value),
|
|
13675
|
+
unit: data.unit ?? null,
|
|
13676
|
+
error: null
|
|
13677
|
+
};
|
|
13678
|
+
}
|
|
12894
13679
|
function bulkActionsFor(admin) {
|
|
12895
13680
|
const actions = [];
|
|
12896
13681
|
const hasActiveFlag = "isActive" in adminColumns(admin.model);
|
|
@@ -12910,6 +13695,55 @@ function bulkActionsFor(admin) {
|
|
|
12910
13695
|
}
|
|
12911
13696
|
return actions;
|
|
12912
13697
|
}
|
|
13698
|
+
function parseCsv(text) {
|
|
13699
|
+
const source = text.charCodeAt(0) === 65279 ? text.slice(1) : text;
|
|
13700
|
+
const rows = [];
|
|
13701
|
+
let row = [];
|
|
13702
|
+
let field = "";
|
|
13703
|
+
let quoted = false;
|
|
13704
|
+
for (let index = 0; index < source.length; index += 1) {
|
|
13705
|
+
const char = source[index];
|
|
13706
|
+
if (quoted) {
|
|
13707
|
+
if (char === '"') {
|
|
13708
|
+
if (source[index + 1] === '"') {
|
|
13709
|
+
field += '"';
|
|
13710
|
+
index += 1;
|
|
13711
|
+
} else {
|
|
13712
|
+
quoted = false;
|
|
13713
|
+
}
|
|
13714
|
+
} else {
|
|
13715
|
+
field += char;
|
|
13716
|
+
}
|
|
13717
|
+
continue;
|
|
13718
|
+
}
|
|
13719
|
+
if (char === '"') {
|
|
13720
|
+
quoted = true;
|
|
13721
|
+
} else if (char === ",") {
|
|
13722
|
+
row.push(field);
|
|
13723
|
+
field = "";
|
|
13724
|
+
} else if (char === "\n" || char === "\r") {
|
|
13725
|
+
if (char === "\r" && source[index + 1] === "\n") index += 1;
|
|
13726
|
+
row.push(field);
|
|
13727
|
+
rows.push(row);
|
|
13728
|
+
row = [];
|
|
13729
|
+
field = "";
|
|
13730
|
+
} else {
|
|
13731
|
+
field += char;
|
|
13732
|
+
}
|
|
13733
|
+
}
|
|
13734
|
+
if (field !== "" || row.length > 0) {
|
|
13735
|
+
row.push(field);
|
|
13736
|
+
rows.push(row);
|
|
13737
|
+
}
|
|
13738
|
+
const header = rows.shift();
|
|
13739
|
+
if (header === void 0 || header.length === 0) {
|
|
13740
|
+
throw new Error("The file has no header row.");
|
|
13741
|
+
}
|
|
13742
|
+
const keys = header.map((name) => name.trim());
|
|
13743
|
+
return rows.filter((entry) => entry.some((value) => value.trim() !== "")).map(
|
|
13744
|
+
(entry) => Object.fromEntries(keys.map((key, position) => [key, entry[position] ?? ""]))
|
|
13745
|
+
);
|
|
13746
|
+
}
|
|
12913
13747
|
function exportValue(value) {
|
|
12914
13748
|
if (value instanceof Date) return value.toISOString();
|
|
12915
13749
|
if (typeof value === "bigint") return value.toString();
|
|
@@ -14943,7 +15777,7 @@ async function withTestDatabase(models, fn) {
|
|
|
14943
15777
|
}
|
|
14944
15778
|
|
|
14945
15779
|
// src/version.ts
|
|
14946
|
-
var VERSION = "0.
|
|
15780
|
+
var VERSION = "0.27.0";
|
|
14947
15781
|
|
|
14948
15782
|
Object.defineProperty(exports, "OpenAPIRegistry", {
|
|
14949
15783
|
enumerable: true,
|
|
@@ -15101,6 +15935,7 @@ exports.ADMIN_CSS = ADMIN_CSS;
|
|
|
15101
15935
|
exports.ActivationService = ActivationService;
|
|
15102
15936
|
exports.AdminJsonSite = AdminJsonSite;
|
|
15103
15937
|
exports.AdminModel = AdminModel;
|
|
15938
|
+
exports.AdminPermission = AdminPermission;
|
|
15104
15939
|
exports.AdminSessionStore = AdminSessionStore;
|
|
15105
15940
|
exports.AdminSite = AdminSite;
|
|
15106
15941
|
exports.AppException = AppException;
|
|
@@ -15156,6 +15991,7 @@ exports.MessageCatalog = MessageCatalog;
|
|
|
15156
15991
|
exports.MessagingHub = MessagingHub;
|
|
15157
15992
|
exports.MetricsUtils = MetricsUtils;
|
|
15158
15993
|
exports.MfaService = MfaService;
|
|
15994
|
+
exports.MultipartLimitError = MultipartLimitError;
|
|
15159
15995
|
exports.NotFoundException = NotFoundException;
|
|
15160
15996
|
exports.OAuthError = OAuthError;
|
|
15161
15997
|
exports.OIDCProvider = OIDCProvider;
|
|
@@ -15201,6 +16037,7 @@ exports.activationSchema = activationSchema;
|
|
|
15201
16037
|
exports.addLogSink = addLogSink;
|
|
15202
16038
|
exports.adminAction = adminAction;
|
|
15203
16039
|
exports.adminColumns = adminColumns;
|
|
16040
|
+
exports.adminLens = adminLens;
|
|
15204
16041
|
exports.adminThemeCss = adminThemeCss;
|
|
15205
16042
|
exports.attachWebSocketHub = attachWebSocketHub;
|
|
15206
16043
|
exports.authResponseSchema = authResponseSchema;
|
|
@@ -15267,6 +16104,7 @@ exports.humanizeField = humanizeField;
|
|
|
15267
16104
|
exports.idempotencyMiddleware = idempotencyMiddleware;
|
|
15268
16105
|
exports.inboundMessageSchema = inboundMessageSchema;
|
|
15269
16106
|
exports.isColumnOptional = isColumnOptional;
|
|
16107
|
+
exports.isMultipart = isMultipart;
|
|
15270
16108
|
exports.isSearchableColumn = isSearchableColumn;
|
|
15271
16109
|
exports.isValidCep = isValidCep;
|
|
15272
16110
|
exports.isValidCity = isValidCity;
|
|
@@ -15302,6 +16140,7 @@ exports.makeToolSpecRouter = makeToolSpecRouter;
|
|
|
15302
16140
|
exports.makeTwilioWebhookRouter = makeTwilioWebhookRouter;
|
|
15303
16141
|
exports.makeUnhandledExceptionHandler = makeUnhandledExceptionHandler;
|
|
15304
16142
|
exports.makeWhatsAppWebhookRouter = makeWhatsAppWebhookRouter;
|
|
16143
|
+
exports.metricCard = metricCard;
|
|
15305
16144
|
exports.mfaChallengeSchema = mfaChallengeSchema;
|
|
15306
16145
|
exports.mfaCodeSchema = mfaCodeSchema;
|
|
15307
16146
|
exports.mfaEnrollResponseSchema = mfaEnrollResponseSchema;
|
|
@@ -15325,7 +16164,10 @@ exports.paginationFilterSchema = paginationFilterSchema;
|
|
|
15325
16164
|
exports.paginationSchema = paginationSchema;
|
|
15326
16165
|
exports.parseAcceptLanguage = parseAcceptLanguage;
|
|
15327
16166
|
exports.parseCookies = parseCookies;
|
|
16167
|
+
exports.parseCsv = parseCsv;
|
|
15328
16168
|
exports.parseFormBody = parseFormBody;
|
|
16169
|
+
exports.parseMultipart = parseMultipart;
|
|
16170
|
+
exports.partitionTotal = partitionTotal;
|
|
15329
16171
|
exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
|
|
15330
16172
|
exports.passwordResetRequestSchema = passwordResetRequestSchema;
|
|
15331
16173
|
exports.percentField = percentField;
|
|
@@ -15346,6 +16188,7 @@ exports.renderAuthResultPage = renderAuthResultPage;
|
|
|
15346
16188
|
exports.renderDashboardPage = renderDashboardPage;
|
|
15347
16189
|
exports.renderDetailPage = renderDetailPage;
|
|
15348
16190
|
exports.renderFormPage = renderFormPage;
|
|
16191
|
+
exports.renderImportPage = renderImportPage;
|
|
15349
16192
|
exports.renderLayout = renderLayout;
|
|
15350
16193
|
exports.renderListPage = renderListPage;
|
|
15351
16194
|
exports.renderLoginPage = renderLoginPage;
|
|
@@ -15378,6 +16221,8 @@ exports.toUtc = toUtc;
|
|
|
15378
16221
|
exports.tokenFromUrl = tokenFromUrl;
|
|
15379
16222
|
exports.tokenPairSchema = tokenPairSchema;
|
|
15380
16223
|
exports.tokenSettingsShape = tokenSettingsShape;
|
|
16224
|
+
exports.trendDirection = trendDirection;
|
|
16225
|
+
exports.trendPercent = trendPercent;
|
|
15381
16226
|
exports.ufField = ufField;
|
|
15382
16227
|
exports.updatedByColumn = updatedByColumn;
|
|
15383
16228
|
exports.uploadSettingsShape = uploadSettingsShape;
|