tempest-express-sdk 0.24.0 → 0.26.0

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