tempest-express-sdk 0.26.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-GLZYNX63.js → chunk-ADF7OHRH.js} +3 -3
- package/dist/{chunk-GLZYNX63.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 +514 -14
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +177 -4
- package/dist/index.d.ts +177 -4
- package/dist/index.js +511 -16
- package/dist/index.js.map +1 -1
- package/package.json +7 -1
package/dist/index.cjs
CHANGED
|
@@ -9498,6 +9498,85 @@ function adminAction(options, handler) {
|
|
|
9498
9498
|
};
|
|
9499
9499
|
}
|
|
9500
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
|
+
|
|
9501
9580
|
// src/admin/columns.ts
|
|
9502
9581
|
function humanizeField(name) {
|
|
9503
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(" ");
|
|
@@ -9591,6 +9670,8 @@ var NEVER_EDITABLE = [
|
|
|
9591
9670
|
"id",
|
|
9592
9671
|
"createdAt",
|
|
9593
9672
|
"updatedAt",
|
|
9673
|
+
"createdBy",
|
|
9674
|
+
"updatedBy",
|
|
9594
9675
|
"hashedPassword"
|
|
9595
9676
|
];
|
|
9596
9677
|
var NEVER_LISTED = ["hashedPassword"];
|
|
@@ -9627,6 +9708,14 @@ var AdminModel = class {
|
|
|
9627
9708
|
auditModel;
|
|
9628
9709
|
/** Saved list-view presets, in declaration order. */
|
|
9629
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;
|
|
9630
9719
|
actions = /* @__PURE__ */ new Map();
|
|
9631
9720
|
slugOverride;
|
|
9632
9721
|
listDisplayOverride;
|
|
@@ -9654,6 +9743,15 @@ var AdminModel = class {
|
|
|
9654
9743
|
this.canDelete = options.canDelete ?? true;
|
|
9655
9744
|
this.auditModel = options.auditModel ?? null;
|
|
9656
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
|
+
}
|
|
9657
9755
|
for (const action of options.actions ?? []) {
|
|
9658
9756
|
if (this.actions.has(action.name)) {
|
|
9659
9757
|
throw new Error(
|
|
@@ -9668,7 +9766,9 @@ var AdminModel = class {
|
|
|
9668
9766
|
["listFilter", this.listFilter],
|
|
9669
9767
|
["searchFields", this.searchFields],
|
|
9670
9768
|
["readonlyFields", this.readonlyFields],
|
|
9671
|
-
["identityField", [this.identityField]]
|
|
9769
|
+
["identityField", [this.identityField]],
|
|
9770
|
+
["uploadFields", this.uploadFields],
|
|
9771
|
+
["autocompleteFields", this.autocompleteFields]
|
|
9672
9772
|
]) {
|
|
9673
9773
|
for (const name of names) {
|
|
9674
9774
|
if (!known.has(name)) {
|
|
@@ -9850,11 +9950,15 @@ function buildFormFields(admin, options = {}) {
|
|
|
9850
9950
|
const values = options.values ?? {};
|
|
9851
9951
|
const errors = options.errors ?? {};
|
|
9852
9952
|
const foreignKeys = options.foreignKeyOptions ?? {};
|
|
9953
|
+
const autocompleteUrls = options.autocompleteUrls ?? {};
|
|
9954
|
+
const autocompleteLabels = options.autocompleteLabels ?? {};
|
|
9955
|
+
const uploads = new Set(admin.uploadFields);
|
|
9853
9956
|
return admin.editableFieldNames().flatMap((name) => {
|
|
9854
9957
|
const column6 = columns[name];
|
|
9855
9958
|
if (column6 === void 0) return [];
|
|
9856
9959
|
const related = foreignKeys[name];
|
|
9857
|
-
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 };
|
|
9858
9962
|
const raw = name in values ? values[name] : literalDefault(column6);
|
|
9859
9963
|
return [
|
|
9860
9964
|
{
|
|
@@ -9866,7 +9970,9 @@ function buildFormFields(admin, options = {}) {
|
|
|
9866
9970
|
checked: spec.widget === "checkbox" && toBoolean(raw),
|
|
9867
9971
|
step: spec.step,
|
|
9868
9972
|
options: spec.options,
|
|
9869
|
-
error: errors[name] ?? null
|
|
9973
|
+
error: errors[name] ?? null,
|
|
9974
|
+
autocompleteUrl: autocompleteUrl ?? null,
|
|
9975
|
+
displayLabel: autocompleteLabels[name] ?? ""
|
|
9870
9976
|
}
|
|
9871
9977
|
];
|
|
9872
9978
|
});
|
|
@@ -9914,13 +10020,15 @@ function coerceValue(column6, widget, raw) {
|
|
|
9914
10020
|
return raw;
|
|
9915
10021
|
}
|
|
9916
10022
|
}
|
|
9917
|
-
function parseFormBody(admin, body) {
|
|
10023
|
+
function parseFormBody(admin, body, options = {}) {
|
|
9918
10024
|
const columns = adminColumns(admin.model);
|
|
9919
10025
|
const data = {};
|
|
9920
10026
|
const errors = {};
|
|
10027
|
+
const uploads = options.uploadsAsText === true ? /* @__PURE__ */ new Set() : new Set(admin.uploadFields);
|
|
9921
10028
|
for (const name of admin.editableFieldNames()) {
|
|
9922
10029
|
const column6 = columns[name];
|
|
9923
10030
|
if (column6 === void 0) continue;
|
|
10031
|
+
if (uploads.has(name)) continue;
|
|
9924
10032
|
const { widget } = widgetForColumn(column6);
|
|
9925
10033
|
if (widget === "checkbox") {
|
|
9926
10034
|
data[name] = toBoolean(body[name]);
|
|
@@ -12206,6 +12314,7 @@ function renderListPage(context, view) {
|
|
|
12206
12314
|
</form>
|
|
12207
12315
|
<div class="tempest-admin-list__actions">
|
|
12208
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>` : ""}
|
|
12209
12318
|
<a href="${escapeHtml(view.exportCsvUrl)}">Export CSV</a>
|
|
12210
12319
|
<a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
|
|
12211
12320
|
</div>
|
|
@@ -12305,6 +12414,18 @@ function renderFormField(field) {
|
|
|
12305
12414
|
control = `<label><span>${label}${field.required ? " *" : ""}</span><select name="${name}"${required}>${blank}${options}</select></label>`;
|
|
12306
12415
|
break;
|
|
12307
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;
|
|
12308
12429
|
case "number":
|
|
12309
12430
|
control = `<label><span>${label}${field.required ? " *" : ""}</span><input type="number" name="${name}" value="${value}"${field.step !== null ? ` step="${escapeHtml(field.step)}"` : ""}${required}></label>`;
|
|
12310
12431
|
break;
|
|
@@ -12332,7 +12453,7 @@ function renderFormPage(context, view) {
|
|
|
12332
12453
|
<a href="${escapeHtml(view.backUrl)}">\u2190 Back</a>
|
|
12333
12454
|
</header>
|
|
12334
12455
|
${view.error !== null ? `<p class="tempest-admin-form__error">${escapeHtml(view.error)}</p>` : ""}
|
|
12335
|
-
<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"' : ""}>
|
|
12336
12457
|
<input type="hidden" name="csrf_token" value="${escapeHtml(context.session.csrfToken)}">
|
|
12337
12458
|
${view.fields.map(renderFormField).join("")}
|
|
12338
12459
|
<div class="tempest-admin-form__actions">
|
|
@@ -12340,10 +12461,97 @@ function renderFormPage(context, view) {
|
|
|
12340
12461
|
<a href="${escapeHtml(view.backUrl)}" class="tempest-admin-form__cancel">Cancel</a>
|
|
12341
12462
|
</div>
|
|
12342
12463
|
</form>
|
|
12464
|
+
${view.fields.some((field) => field.widget === "autocomplete") ? AUTOCOMPLETE_SCRIPT : ""}
|
|
12343
12465
|
</section>`;
|
|
12344
12466
|
return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
|
|
12345
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>`;
|
|
12346
12553
|
var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
|
|
12554
|
+
var AUTOCOMPLETE_LIMIT = 20;
|
|
12347
12555
|
var AUDIT_HISTORY_LIMIT = 50;
|
|
12348
12556
|
var FK_OPTION_CAP = 1e3;
|
|
12349
12557
|
var FLASH_MAX_LENGTH = 300;
|
|
@@ -12373,6 +12581,7 @@ function makeAdminRouter(site, options) {
|
|
|
12373
12581
|
const theme = resolveAdminTheme(site.theme);
|
|
12374
12582
|
const showMetrics = options.showMetrics ?? true;
|
|
12375
12583
|
const exportMaxRows = options.exportMaxRows ?? 5e3;
|
|
12584
|
+
const maxUploadBytes = options.maxUploadBytes ?? 10 * 1024 * 1024;
|
|
12376
12585
|
const sessions = new AdminSessionStore({
|
|
12377
12586
|
secret: options.secretKey,
|
|
12378
12587
|
...options.cookieName === void 0 ? {} : { cookieName: options.cookieName },
|
|
@@ -12630,7 +12839,8 @@ function makeAdminRouter(site, options) {
|
|
|
12630
12839
|
mode: "create",
|
|
12631
12840
|
title: admin.verboseName(),
|
|
12632
12841
|
fields: buildFormFields(admin, {
|
|
12633
|
-
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession)
|
|
12842
|
+
foreignKeyOptions: await foreignKeyOptionsFor(admin, state.dbSession),
|
|
12843
|
+
autocompleteUrls: autocompleteUrlsFor(admin)
|
|
12634
12844
|
}),
|
|
12635
12845
|
actionUrl: `${prefix}/m/${admin.slug()}/new`,
|
|
12636
12846
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
@@ -12650,10 +12860,28 @@ function makeAdminRouter(site, options) {
|
|
|
12650
12860
|
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12651
12861
|
return;
|
|
12652
12862
|
}
|
|
12653
|
-
|
|
12654
|
-
|
|
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);
|
|
12875
|
+
return;
|
|
12876
|
+
}
|
|
12655
12877
|
const parsed = parseFormBody(admin, body);
|
|
12878
|
+
await applyUploads(admin, parsed.data, parsed.errors, submission.files, true);
|
|
12656
12879
|
const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
|
|
12880
|
+
const autocompleteLabels = await autocompleteLabelsFor(
|
|
12881
|
+
admin,
|
|
12882
|
+
body,
|
|
12883
|
+
state.dbSession
|
|
12884
|
+
);
|
|
12657
12885
|
const rerender = (error, status) => {
|
|
12658
12886
|
html(
|
|
12659
12887
|
res,
|
|
@@ -12663,7 +12891,9 @@ function makeAdminRouter(site, options) {
|
|
|
12663
12891
|
fields: buildFormFields(admin, {
|
|
12664
12892
|
values: body,
|
|
12665
12893
|
errors: parsed.errors,
|
|
12666
|
-
foreignKeyOptions
|
|
12894
|
+
foreignKeyOptions,
|
|
12895
|
+
autocompleteUrls: autocompleteUrlsFor(admin),
|
|
12896
|
+
autocompleteLabels
|
|
12667
12897
|
}),
|
|
12668
12898
|
actionUrl: `${prefix}/m/${admin.slug()}/new`,
|
|
12669
12899
|
backUrl: `${prefix}/m/${admin.slug()}`,
|
|
@@ -12672,6 +12902,10 @@ function makeAdminRouter(site, options) {
|
|
|
12672
12902
|
status
|
|
12673
12903
|
);
|
|
12674
12904
|
};
|
|
12905
|
+
if (uploadError !== null) {
|
|
12906
|
+
rerender(uploadError, 400);
|
|
12907
|
+
return;
|
|
12908
|
+
}
|
|
12675
12909
|
if (Object.keys(parsed.errors).length > 0) {
|
|
12676
12910
|
rerender("Please fix the highlighted fields.", 400);
|
|
12677
12911
|
return;
|
|
@@ -12791,6 +13025,129 @@ function makeAdminRouter(site, options) {
|
|
|
12791
13025
|
);
|
|
12792
13026
|
})
|
|
12793
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
|
+
);
|
|
12794
13151
|
router.get(
|
|
12795
13152
|
`${prefix}/m/:slug/:identity`,
|
|
12796
13153
|
guarded(async (req, res) => {
|
|
@@ -12838,7 +13195,9 @@ function makeAdminRouter(site, options) {
|
|
|
12838
13195
|
title: admin.verboseName(),
|
|
12839
13196
|
fields: buildFormFields(admin, {
|
|
12840
13197
|
values: row,
|
|
12841
|
-
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)
|
|
12842
13201
|
}),
|
|
12843
13202
|
actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
|
|
12844
13203
|
backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
|
|
@@ -12859,10 +13218,28 @@ function makeAdminRouter(site, options) {
|
|
|
12859
13218
|
html(res, renderNotFound(context(req, state.session, state.visible)), 404);
|
|
12860
13219
|
return;
|
|
12861
13220
|
}
|
|
12862
|
-
|
|
12863
|
-
|
|
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);
|
|
13233
|
+
return;
|
|
13234
|
+
}
|
|
12864
13235
|
const parsed = parseFormBody(admin, body);
|
|
13236
|
+
await applyUploads(admin, parsed.data, parsed.errors, submission.files, false);
|
|
12865
13237
|
const foreignKeyOptions = await foreignKeyOptionsFor(admin, state.dbSession);
|
|
13238
|
+
const autocompleteLabels = await autocompleteLabelsFor(
|
|
13239
|
+
admin,
|
|
13240
|
+
body,
|
|
13241
|
+
state.dbSession
|
|
13242
|
+
);
|
|
12866
13243
|
const rerender = (error, status) => {
|
|
12867
13244
|
html(
|
|
12868
13245
|
res,
|
|
@@ -12872,7 +13249,9 @@ function makeAdminRouter(site, options) {
|
|
|
12872
13249
|
fields: buildFormFields(admin, {
|
|
12873
13250
|
values: body,
|
|
12874
13251
|
errors: parsed.errors,
|
|
12875
|
-
foreignKeyOptions
|
|
13252
|
+
foreignKeyOptions,
|
|
13253
|
+
autocompleteUrls: autocompleteUrlsFor(admin),
|
|
13254
|
+
autocompleteLabels
|
|
12876
13255
|
}),
|
|
12877
13256
|
actionUrl: `${prefix}/m/${admin.slug()}/${identity}/edit`,
|
|
12878
13257
|
backUrl: `${prefix}/m/${admin.slug()}/${identity}`,
|
|
@@ -12881,6 +13260,10 @@ function makeAdminRouter(site, options) {
|
|
|
12881
13260
|
status
|
|
12882
13261
|
);
|
|
12883
13262
|
};
|
|
13263
|
+
if (uploadError !== null) {
|
|
13264
|
+
rerender(uploadError, 400);
|
|
13265
|
+
return;
|
|
13266
|
+
}
|
|
12884
13267
|
if (Object.keys(parsed.errors).length > 0) {
|
|
12885
13268
|
rerender("Please fix the highlighted fields.", 400);
|
|
12886
13269
|
return;
|
|
@@ -12964,6 +13347,44 @@ function makeAdminRouter(site, options) {
|
|
|
12964
13347
|
const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
|
|
12965
13348
|
return row ?? null;
|
|
12966
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
|
+
}
|
|
12967
13388
|
async function permittedBulkActions(admin, principal) {
|
|
12968
13389
|
const permitted = [];
|
|
12969
13390
|
for (const option of bulkActionsFor(admin)) {
|
|
@@ -13027,6 +13448,7 @@ function makeAdminRouter(site, options) {
|
|
|
13027
13448
|
filters: query.filterViews,
|
|
13028
13449
|
sort,
|
|
13029
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,
|
|
13030
13452
|
bulkActions: await permittedBulkActions(admin, state.principal),
|
|
13031
13453
|
bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
|
|
13032
13454
|
exportCsvUrl: exportUrl("csv"),
|
|
@@ -13151,6 +13573,7 @@ function makeAdminRouter(site, options) {
|
|
|
13151
13573
|
const columns = adminColumns(admin.model);
|
|
13152
13574
|
const options2 = {};
|
|
13153
13575
|
for (const field of Object.keys(foreignKeyFields(admin))) {
|
|
13576
|
+
if (admin.autocompleteFields.includes(field)) continue;
|
|
13154
13577
|
const column6 = columns[field];
|
|
13155
13578
|
if (column6 === void 0) continue;
|
|
13156
13579
|
const related = await relatedOptions(column6, dbSession);
|
|
@@ -13158,6 +13581,29 @@ function makeAdminRouter(site, options) {
|
|
|
13158
13581
|
}
|
|
13159
13582
|
return options2;
|
|
13160
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
|
+
}
|
|
13161
13607
|
return router;
|
|
13162
13608
|
}
|
|
13163
13609
|
function flagAllows(admin, action) {
|
|
@@ -13249,6 +13695,55 @@ function bulkActionsFor(admin) {
|
|
|
13249
13695
|
}
|
|
13250
13696
|
return actions;
|
|
13251
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
|
+
}
|
|
13252
13747
|
function exportValue(value) {
|
|
13253
13748
|
if (value instanceof Date) return value.toISOString();
|
|
13254
13749
|
if (typeof value === "bigint") return value.toString();
|
|
@@ -15282,7 +15777,7 @@ async function withTestDatabase(models, fn) {
|
|
|
15282
15777
|
}
|
|
15283
15778
|
|
|
15284
15779
|
// src/version.ts
|
|
15285
|
-
var VERSION = "0.
|
|
15780
|
+
var VERSION = "0.27.0";
|
|
15286
15781
|
|
|
15287
15782
|
Object.defineProperty(exports, "OpenAPIRegistry", {
|
|
15288
15783
|
enumerable: true,
|
|
@@ -15496,6 +15991,7 @@ exports.MessageCatalog = MessageCatalog;
|
|
|
15496
15991
|
exports.MessagingHub = MessagingHub;
|
|
15497
15992
|
exports.MetricsUtils = MetricsUtils;
|
|
15498
15993
|
exports.MfaService = MfaService;
|
|
15994
|
+
exports.MultipartLimitError = MultipartLimitError;
|
|
15499
15995
|
exports.NotFoundException = NotFoundException;
|
|
15500
15996
|
exports.OAuthError = OAuthError;
|
|
15501
15997
|
exports.OIDCProvider = OIDCProvider;
|
|
@@ -15608,6 +16104,7 @@ exports.humanizeField = humanizeField;
|
|
|
15608
16104
|
exports.idempotencyMiddleware = idempotencyMiddleware;
|
|
15609
16105
|
exports.inboundMessageSchema = inboundMessageSchema;
|
|
15610
16106
|
exports.isColumnOptional = isColumnOptional;
|
|
16107
|
+
exports.isMultipart = isMultipart;
|
|
15611
16108
|
exports.isSearchableColumn = isSearchableColumn;
|
|
15612
16109
|
exports.isValidCep = isValidCep;
|
|
15613
16110
|
exports.isValidCity = isValidCity;
|
|
@@ -15667,7 +16164,9 @@ exports.paginationFilterSchema = paginationFilterSchema;
|
|
|
15667
16164
|
exports.paginationSchema = paginationSchema;
|
|
15668
16165
|
exports.parseAcceptLanguage = parseAcceptLanguage;
|
|
15669
16166
|
exports.parseCookies = parseCookies;
|
|
16167
|
+
exports.parseCsv = parseCsv;
|
|
15670
16168
|
exports.parseFormBody = parseFormBody;
|
|
16169
|
+
exports.parseMultipart = parseMultipart;
|
|
15671
16170
|
exports.partitionTotal = partitionTotal;
|
|
15672
16171
|
exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
|
|
15673
16172
|
exports.passwordResetRequestSchema = passwordResetRequestSchema;
|
|
@@ -15689,6 +16188,7 @@ exports.renderAuthResultPage = renderAuthResultPage;
|
|
|
15689
16188
|
exports.renderDashboardPage = renderDashboardPage;
|
|
15690
16189
|
exports.renderDetailPage = renderDetailPage;
|
|
15691
16190
|
exports.renderFormPage = renderFormPage;
|
|
16191
|
+
exports.renderImportPage = renderImportPage;
|
|
15692
16192
|
exports.renderLayout = renderLayout;
|
|
15693
16193
|
exports.renderListPage = renderListPage;
|
|
15694
16194
|
exports.renderLoginPage = renderLoginPage;
|