tempest-express-sdk 0.25.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,12 +9444,51 @@ var MessagingHub = class {
9444
9444
  }
9445
9445
  };
9446
9446
 
9447
+ // src/admin/dashboard.ts
9448
+ function metricCard(label, compute, helpText) {
9449
+ return helpText === void 0 ? { label, compute } : { label, compute, helpText };
9450
+ }
9451
+ function trendPercent(trend) {
9452
+ if (trend.previous === 0) return null;
9453
+ return (trend.value - trend.previous) / trend.previous * 100;
9454
+ }
9455
+ function trendDirection(trend) {
9456
+ if (trend.value > trend.previous) return "up";
9457
+ if (trend.value < trend.previous) return "down";
9458
+ return "flat";
9459
+ }
9460
+ function partitionTotal(partition) {
9461
+ return partition.segments.reduce((total, segment) => total + segment.value, 0);
9462
+ }
9463
+
9464
+ // src/admin/lenses.ts
9465
+ function slugify(name) {
9466
+ return name.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "lens";
9467
+ }
9468
+ function adminLens(options) {
9469
+ return {
9470
+ name: options.name,
9471
+ slug: slugify(options.name),
9472
+ label: options.label ?? options.name,
9473
+ filters: options.filters ?? {},
9474
+ orderBy: options.orderBy ?? null
9475
+ };
9476
+ }
9477
+
9478
+ // src/admin/permissions.ts
9479
+ var AdminPermission = {
9480
+ VIEW: "view",
9481
+ CREATE: "create",
9482
+ EDIT: "edit",
9483
+ DELETE: "delete"
9484
+ };
9485
+
9447
9486
  // src/admin/actions.ts
9448
- function slugify(label) {
9487
+ function slugify2(label) {
9449
9488
  return label.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "action";
9450
9489
  }
9451
9490
  function adminAction(options, handler) {
9452
- const name = options.name ?? slugify(options.label);
9491
+ const name = options.name ?? slugify2(options.label);
9453
9492
  if (name === "") throw new Error("adminAction requires a non-empty name or label");
9454
9493
  return {
9455
9494
  name,
@@ -9555,6 +9594,12 @@ var NEVER_EDITABLE = [
9555
9594
  "hashedPassword"
9556
9595
  ];
9557
9596
  var NEVER_LISTED = ["hashedPassword"];
9597
+ var AUDIT_FIELDS = [
9598
+ "createdAt",
9599
+ "updatedAt",
9600
+ "createdBy",
9601
+ "updatedBy"
9602
+ ];
9558
9603
  var AdminModel = class {
9559
9604
  /** The managed model class. */
9560
9605
  model;
@@ -9578,6 +9623,10 @@ var AdminModel = class {
9578
9623
  canEdit;
9579
9624
  /** Whether the delete action is exposed. */
9580
9625
  canDelete;
9626
+ /** Audit-log model backing the detail timeline, or `null`. */
9627
+ auditModel;
9628
+ /** Saved list-view presets, in declaration order. */
9629
+ lenses;
9581
9630
  actions = /* @__PURE__ */ new Map();
9582
9631
  slugOverride;
9583
9632
  listDisplayOverride;
@@ -9603,6 +9652,8 @@ var AdminModel = class {
9603
9652
  this.canCreate = options.canCreate ?? true;
9604
9653
  this.canEdit = options.canEdit ?? true;
9605
9654
  this.canDelete = options.canDelete ?? true;
9655
+ this.auditModel = options.auditModel ?? null;
9656
+ this.lenses = [...options.lenses ?? []];
9606
9657
  for (const action of options.actions ?? []) {
9607
9658
  if (this.actions.has(action.name)) {
9608
9659
  throw new Error(
@@ -9687,6 +9738,25 @@ var AdminModel = class {
9687
9738
  if (this.listDisplayOverride !== null) return [...this.listDisplayOverride];
9688
9739
  return this.columnNames().filter((name) => !NEVER_LISTED.includes(name));
9689
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
+ }
9690
9760
  /**
9691
9761
  * Return the columns the detail view renders.
9692
9762
  *
@@ -9695,10 +9765,16 @@ var AdminModel = class {
9695
9765
  * where an operator goes to see the whole record, so trimming it there would
9696
9766
  * hide data with nowhere else to read it.
9697
9767
  *
9698
- * @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.
9699
9773
  */
9700
9774
  detailFieldNames() {
9701
- 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
+ );
9702
9778
  }
9703
9779
  /**
9704
9780
  * Return the columns a create/edit form exposes.
@@ -10155,6 +10231,8 @@ var AdminSite = class {
10155
10231
  siteUrl;
10156
10232
  /** Typed appearance overrides. */
10157
10233
  theme;
10234
+ /** Business-metric cards rendered at the top of the dashboard. */
10235
+ dashboardCards;
10158
10236
  registry = /* @__PURE__ */ new Map();
10159
10237
  /**
10160
10238
  * Initialize the site.
@@ -10167,6 +10245,7 @@ var AdminSite = class {
10167
10245
  this.indexSubtitle = options.indexSubtitle ?? "Site administration";
10168
10246
  this.siteUrl = options.siteUrl ?? null;
10169
10247
  this.theme = options.theme ?? {};
10248
+ this.dashboardCards = [...options.dashboardCards ?? []];
10170
10249
  }
10171
10250
  /**
10172
10251
  * Return the centered header brand text.
@@ -11986,7 +12065,7 @@ function renderMfaPage(context, error) {
11986
12065
  </section>`;
11987
12066
  return renderLayout(context, `Two-factor \xB7 ${context.site.title}`, body);
11988
12067
  }
11989
- function renderDashboardPage(context, cards, metrics) {
12068
+ function renderDashboardPage(context, cards, metrics, businessCards = []) {
11990
12069
  const metricsPanel = metrics === null ? "" : `<div class="tempest-admin-stats" aria-label="System metrics">
11991
12070
  <div class="tempest-admin-stat">
11992
12071
  <span class="tempest-admin-stat__label">CPU</span>
@@ -11998,6 +12077,7 @@ function renderDashboardPage(context, cards, metrics) {
11998
12077
  <span class="tempest-admin-stat__sub">${escapeHtml(metrics.memoryUsedGb)} / ${escapeHtml(metrics.memoryTotalGb)} GB</span>
11999
12078
  </div>
12000
12079
  </div>`;
12080
+ const business = businessCards.length > 0 ? `<div class="tempest-admin-cards" aria-label="Business metrics">${businessCards.map(renderBusinessCard).join("")}</div>` : "";
12001
12081
  const models = cards.length > 0 ? `<div class="tempest-admin-models">${cards.map(
12002
12082
  (card) => `<article class="tempest-admin-model-card">
12003
12083
  <header class="tempest-admin-model-card__head">
@@ -12014,10 +12094,53 @@ function renderDashboardPage(context, cards, metrics) {
12014
12094
  <h1>${escapeHtml(context.site.title)}</h1>
12015
12095
  <p>${escapeHtml(context.site.indexSubtitle)}</p>
12016
12096
  ${metricsPanel}
12097
+ ${business}
12017
12098
  ${models}
12018
12099
  </section>`;
12019
12100
  return renderLayout(context, context.site.title, body);
12020
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
+ }
12021
12144
  function renderFilter(filter) {
12022
12145
  const name = `filter_${filter.field}`;
12023
12146
  if (filter.kind === "select") {
@@ -12087,6 +12210,9 @@ function renderListPage(context, view) {
12087
12210
  <a href="${escapeHtml(view.exportJsonUrl)}">Export JSON</a>
12088
12211
  </div>
12089
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>` : ""}
12090
12216
  ${bulkBar}
12091
12217
  <div class="tempest-admin-table-wrap">
12092
12218
  <table class="tempest-admin-list__table">
@@ -12110,6 +12236,7 @@ function renderDetailPage(context, view) {
12110
12236
  const fields = view.fields.map(
12111
12237
  (field) => `<dt>${escapeHtml(field.label)}</dt><dd>${field.value === "" ? "<em>\u2014</em>" : escapeHtml(field.value)}</dd>`
12112
12238
  ).join("");
12239
+ const auditPanel = view.audit === null ? "" : renderAuditPanel(view.audit);
12113
12240
  const body = `<section class="tempest-admin-detail">
12114
12241
  <header class="tempest-admin-detail__header">
12115
12242
  <h1>${escapeHtml(view.title)} \xB7 ${escapeHtml(view.identity)}</h1>
@@ -12123,9 +12250,37 @@ function renderDetailPage(context, view) {
12123
12250
  </div>
12124
12251
  </header>
12125
12252
  <dl class="tempest-admin-detail__fields">${fields}</dl>
12253
+ ${auditPanel}
12126
12254
  </section>`;
12127
12255
  return renderLayout(context, `${view.title} \xB7 ${view.identity}`, body);
12128
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
+ }
12129
12284
  function renderFormField(field) {
12130
12285
  const required = field.required ? " required" : "";
12131
12286
  const name = escapeHtml(field.name);
@@ -12189,6 +12344,7 @@ function renderFormPage(context, view) {
12189
12344
  return renderLayout(context, `${heading} \xB7 ${context.site.title}`, body);
12190
12345
  }
12191
12346
  var logger2 = new JSONLogger("tempest_express_sdk.admin.router");
12347
+ var AUDIT_HISTORY_LIMIT = 50;
12192
12348
  var FK_OPTION_CAP = 1e3;
12193
12349
  var FLASH_MAX_LENGTH = 300;
12194
12350
  var FLASH_MESSAGES = {
@@ -12227,18 +12383,23 @@ function makeAdminRouter(site, options) {
12227
12383
  const backend = options.authBackend;
12228
12384
  const router = express3__default.default.Router();
12229
12385
  router.use(prefix, express3__default.default.urlencoded({ extended: false }));
12230
- const context = (req, session) => ({
12386
+ const context = (req, session, models = site.list()) => ({
12231
12387
  site,
12232
12388
  theme,
12233
12389
  prefix,
12234
12390
  session,
12235
12391
  currentPath: req.originalUrl.split("?")[0] ?? req.path,
12236
- navModels: site.list().map((admin) => ({
12392
+ navModels: models.map((admin) => ({
12237
12393
  label: admin.verboseNamePlural(),
12238
12394
  url: `${prefix}/m/${admin.slug()}`
12239
12395
  })),
12240
12396
  messages: flashFor(req)
12241
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
+ };
12242
12403
  const flashFor = (req) => {
12243
12404
  const fixed = FLASH_MESSAGES[queryString(req.query.ok)];
12244
12405
  if (fixed !== void 0) return [fixed];
@@ -12286,14 +12447,28 @@ function makeAdminRouter(site, options) {
12286
12447
  res.redirect(`${prefix}/mfa`);
12287
12448
  return null;
12288
12449
  }
12289
- return { session, dbSession, principal };
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 };
12290
12455
  };
12291
- const resolveAdmin = (req, res, state) => {
12456
+ const resolveAdmin = async (req, res, state, action = AdminPermission.VIEW) => {
12292
12457
  const admin = site.get(String(req.params.slug));
12293
- if (admin === null) {
12294
- 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);
12295
12460
  return null;
12296
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
+ }
12297
12472
  return admin;
12298
12473
  };
12299
12474
  const renderNotFound = (ctx) => renderDashboardPage(
@@ -12304,7 +12479,7 @@ function makeAdminRouter(site, options) {
12304
12479
  const checkCsrf = (req, res, state) => {
12305
12480
  const body = req.body;
12306
12481
  if (csrfTokenMatches(state.session, body?.csrf_token)) return true;
12307
- html(res, renderNotFound(context(req, state.session)), 403);
12482
+ html(res, renderNotFound(context(req, state.session, state.visible)), 403);
12308
12483
  return false;
12309
12484
  };
12310
12485
  router.get(`${prefix}/static/admin.css`, (_req, res) => {
@@ -12386,7 +12561,7 @@ function makeAdminRouter(site, options) {
12386
12561
  const state = await authenticate(req, res);
12387
12562
  if (state === null) return;
12388
12563
  const cards = [];
12389
- for (const admin of site.list()) {
12564
+ for (const admin of state.visible) {
12390
12565
  let count = null;
12391
12566
  try {
12392
12567
  count = await admin.repository(state.dbSession).count();
@@ -12400,9 +12575,13 @@ function makeAdminRouter(site, options) {
12400
12575
  label: admin.verboseNamePlural(),
12401
12576
  count,
12402
12577
  url: `${prefix}/m/${admin.slug()}`,
12403
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null
12578
+ newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null
12404
12579
  });
12405
12580
  }
12581
+ const businessCards = [];
12582
+ for (const card of site.dashboardCards) {
12583
+ businessCards.push(await computeBusinessCard(card, state.dbSession));
12584
+ }
12406
12585
  let metrics = null;
12407
12586
  if (showMetrics) {
12408
12587
  const snapshot2 = MetricsUtils.system();
@@ -12413,7 +12592,15 @@ function makeAdminRouter(site, options) {
12413
12592
  memoryTotalGb: (snapshot2.memory.total / 1024 ** 3).toFixed(1)
12414
12593
  };
12415
12594
  }
12416
- 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
+ );
12417
12604
  })
12418
12605
  );
12419
12606
  router.get(
@@ -12421,7 +12608,7 @@ function makeAdminRouter(site, options) {
12421
12608
  guarded(async (req, res) => {
12422
12609
  const state = await authenticate(req, res);
12423
12610
  if (state === null) return;
12424
- const admin = resolveAdmin(req, res, state);
12611
+ const admin = await resolveAdmin(req, res, state);
12425
12612
  if (admin === null) return;
12426
12613
  html(res, await renderList(req, admin, state));
12427
12614
  })
@@ -12431,15 +12618,15 @@ function makeAdminRouter(site, options) {
12431
12618
  guarded(async (req, res) => {
12432
12619
  const state = await authenticate(req, res);
12433
12620
  if (state === null) return;
12434
- const admin = resolveAdmin(req, res, state);
12621
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12435
12622
  if (admin === null) return;
12436
12623
  if (!admin.canCreate) {
12437
- html(res, renderNotFound(context(req, state.session)), 404);
12624
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12438
12625
  return;
12439
12626
  }
12440
12627
  html(
12441
12628
  res,
12442
- renderFormPage(context(req, state.session), {
12629
+ renderFormPage(context(req, state.session, state.visible), {
12443
12630
  mode: "create",
12444
12631
  title: admin.verboseName(),
12445
12632
  fields: buildFormFields(admin, {
@@ -12457,10 +12644,10 @@ function makeAdminRouter(site, options) {
12457
12644
  guarded(async (req, res) => {
12458
12645
  const state = await authenticate(req, res);
12459
12646
  if (state === null) return;
12460
- const admin = resolveAdmin(req, res, state);
12647
+ const admin = await resolveAdmin(req, res, state, AdminPermission.CREATE);
12461
12648
  if (admin === null) return;
12462
12649
  if (!admin.canCreate) {
12463
- html(res, renderNotFound(context(req, state.session)), 404);
12650
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12464
12651
  return;
12465
12652
  }
12466
12653
  if (!checkCsrf(req, res, state)) return;
@@ -12470,7 +12657,7 @@ function makeAdminRouter(site, options) {
12470
12657
  const rerender = (error, status) => {
12471
12658
  html(
12472
12659
  res,
12473
- renderFormPage(context(req, state.session), {
12660
+ renderFormPage(context(req, state.session, state.visible), {
12474
12661
  mode: "create",
12475
12662
  title: admin.verboseName(),
12476
12663
  fields: buildFormFields(admin, {
@@ -12489,6 +12676,7 @@ function makeAdminRouter(site, options) {
12489
12676
  rerender("Please fix the highlighted fields.", 400);
12490
12677
  return;
12491
12678
  }
12679
+ stampActor(admin, parsed.data, backend.principalId(state.principal), true);
12492
12680
  try {
12493
12681
  await admin.repository(state.dbSession).create(parsed.data);
12494
12682
  } catch (error) {
@@ -12503,11 +12691,11 @@ function makeAdminRouter(site, options) {
12503
12691
  guarded(async (req, res) => {
12504
12692
  const state = await authenticate(req, res);
12505
12693
  if (state === null) return;
12506
- const admin = resolveAdmin(req, res, state);
12694
+ const admin = await resolveAdmin(req, res, state);
12507
12695
  if (admin === null) return;
12508
12696
  const format = String(req.params.format);
12509
12697
  if (format !== "csv" && format !== "json") {
12510
- html(res, renderNotFound(context(req, state.session)), 404);
12698
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12511
12699
  return;
12512
12700
  }
12513
12701
  const query = await resolveListQuery(req, admin, state.dbSession);
@@ -12529,7 +12717,7 @@ function makeAdminRouter(site, options) {
12529
12717
  guarded(async (req, res) => {
12530
12718
  const state = await authenticate(req, res);
12531
12719
  if (state === null) return;
12532
- const admin = resolveAdmin(req, res, state);
12720
+ const admin = await resolveAdmin(req, res, state);
12533
12721
  if (admin === null) return;
12534
12722
  if (!checkCsrf(req, res, state)) return;
12535
12723
  const body = req.body;
@@ -12545,7 +12733,12 @@ function makeAdminRouter(site, options) {
12545
12733
  return;
12546
12734
  }
12547
12735
  if (!bulkActionsFor(admin).some((option) => option.value === action)) {
12548
- html(res, renderNotFound(context(req, state.session)), 400);
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);
12549
12742
  return;
12550
12743
  }
12551
12744
  const repository = admin.repository(state.dbSession);
@@ -12553,7 +12746,7 @@ function makeAdminRouter(site, options) {
12553
12746
  if (action.startsWith("custom:")) {
12554
12747
  const custom = admin.getAction(action.slice("custom:".length));
12555
12748
  if (custom === null) {
12556
- html(res, renderNotFound(context(req, state.session)), 400);
12749
+ html(res, renderNotFound(context(req, state.session, state.visible)), 400);
12557
12750
  return;
12558
12751
  }
12559
12752
  let result;
@@ -12603,23 +12796,24 @@ function makeAdminRouter(site, options) {
12603
12796
  guarded(async (req, res) => {
12604
12797
  const state = await authenticate(req, res);
12605
12798
  if (state === null) return;
12606
- const admin = resolveAdmin(req, res, state);
12799
+ const admin = await resolveAdmin(req, res, state);
12607
12800
  if (admin === null) return;
12608
12801
  const row = await findRow(admin, state.dbSession, String(req.params.identity));
12609
12802
  if (row === null) {
12610
- html(res, renderNotFound(context(req, state.session)), 404);
12803
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12611
12804
  return;
12612
12805
  }
12613
12806
  const identity = String(row[admin.identityField]);
12614
12807
  html(
12615
12808
  res,
12616
- renderDetailPage(context(req, state.session), {
12809
+ renderDetailPage(context(req, state.session, state.visible), {
12617
12810
  title: admin.verboseName(),
12618
12811
  identity,
12812
+ audit: await buildAuditView(admin, row, state.dbSession),
12619
12813
  fields: admin.detailFieldNames().map((name) => ({ label: name, value: formatCellValue(row[name]) })),
12620
12814
  backUrl: `${prefix}/m/${admin.slug()}`,
12621
- editUrl: admin.canEdit ? `${prefix}/m/${admin.slug()}/${identity}/edit` : null,
12622
- 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
12623
12817
  })
12624
12818
  );
12625
12819
  })
@@ -12629,17 +12823,17 @@ function makeAdminRouter(site, options) {
12629
12823
  guarded(async (req, res) => {
12630
12824
  const state = await authenticate(req, res);
12631
12825
  if (state === null) return;
12632
- const admin = resolveAdmin(req, res, state);
12826
+ const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
12633
12827
  if (admin === null) return;
12634
12828
  const identity = String(req.params.identity);
12635
12829
  const row = admin.canEdit ? await findRow(admin, state.dbSession, identity) : null;
12636
12830
  if (row === null) {
12637
- html(res, renderNotFound(context(req, state.session)), 404);
12831
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12638
12832
  return;
12639
12833
  }
12640
12834
  html(
12641
12835
  res,
12642
- renderFormPage(context(req, state.session), {
12836
+ renderFormPage(context(req, state.session, state.visible), {
12643
12837
  mode: "edit",
12644
12838
  title: admin.verboseName(),
12645
12839
  fields: buildFormFields(admin, {
@@ -12658,11 +12852,11 @@ function makeAdminRouter(site, options) {
12658
12852
  guarded(async (req, res) => {
12659
12853
  const state = await authenticate(req, res);
12660
12854
  if (state === null) return;
12661
- const admin = resolveAdmin(req, res, state);
12855
+ const admin = await resolveAdmin(req, res, state, AdminPermission.EDIT);
12662
12856
  if (admin === null) return;
12663
12857
  const identity = String(req.params.identity);
12664
12858
  if (!admin.canEdit) {
12665
- html(res, renderNotFound(context(req, state.session)), 404);
12859
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12666
12860
  return;
12667
12861
  }
12668
12862
  if (!checkCsrf(req, res, state)) return;
@@ -12672,7 +12866,7 @@ function makeAdminRouter(site, options) {
12672
12866
  const rerender = (error, status) => {
12673
12867
  html(
12674
12868
  res,
12675
- renderFormPage(context(req, state.session), {
12869
+ renderFormPage(context(req, state.session, state.visible), {
12676
12870
  mode: "edit",
12677
12871
  title: admin.verboseName(),
12678
12872
  fields: buildFormFields(admin, {
@@ -12691,10 +12885,16 @@ function makeAdminRouter(site, options) {
12691
12885
  rerender("Please fix the highlighted fields.", 400);
12692
12886
  return;
12693
12887
  }
12888
+ stampActor(
12889
+ admin,
12890
+ parsed.data,
12891
+ backend.principalId(state.principal),
12892
+ false
12893
+ );
12694
12894
  try {
12695
12895
  const changed = await admin.repository(state.dbSession).update({ [admin.identityField]: identity }, parsed.data);
12696
12896
  if (changed === 0) {
12697
- html(res, renderNotFound(context(req, state.session)), 404);
12897
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12698
12898
  return;
12699
12899
  }
12700
12900
  } catch (error) {
@@ -12709,10 +12909,10 @@ function makeAdminRouter(site, options) {
12709
12909
  guarded(async (req, res) => {
12710
12910
  const state = await authenticate(req, res);
12711
12911
  if (state === null) return;
12712
- const admin = resolveAdmin(req, res, state);
12912
+ const admin = await resolveAdmin(req, res, state, AdminPermission.DELETE);
12713
12913
  if (admin === null) return;
12714
12914
  if (!admin.canDelete) {
12715
- html(res, renderNotFound(context(req, state.session)), 404);
12915
+ html(res, renderNotFound(context(req, state.session, state.visible)), 404);
12716
12916
  return;
12717
12917
  }
12718
12918
  if (!checkCsrf(req, res, state)) return;
@@ -12720,10 +12920,58 @@ function makeAdminRouter(site, options) {
12720
12920
  res.redirect(`${prefix}/m/${admin.slug()}?ok=deleted`);
12721
12921
  })
12722
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
+ }
12723
12963
  async function findRow(admin, dbSession, identity) {
12724
12964
  const row = await admin.repository(dbSession).first({ [admin.identityField]: identity });
12725
12965
  return row ?? null;
12726
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
+ }
12727
12975
  async function renderList(req, admin, state) {
12728
12976
  const columns = adminColumns(admin.model);
12729
12977
  const query = await resolveListQuery(req, admin, state.dbSession);
@@ -12778,21 +13026,36 @@ function makeAdminRouter(site, options) {
12778
13026
  searchValue: query.search,
12779
13027
  filters: query.filterViews,
12780
13028
  sort,
12781
- newUrl: admin.canCreate ? `${prefix}/m/${admin.slug()}/new` : null,
12782
- bulkActions: bulkActionsFor(admin),
13029
+ newUrl: await allows(state.principal, admin, AdminPermission.CREATE) ? `${prefix}/m/${admin.slug()}/new` : null,
13030
+ bulkActions: await permittedBulkActions(admin, state.principal),
12783
13031
  bulkUrl: `${prefix}/m/${admin.slug()}/bulk`,
12784
13032
  exportCsvUrl: exportUrl("csv"),
12785
- exportJsonUrl: exportUrl("json")
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
+ ]
12786
13046
  };
12787
- return renderListPage(context(req, state.session), view);
13047
+ return renderListPage(context(req, state.session, state.visible), view);
12788
13048
  }
12789
13049
  async function resolveListQuery(req, admin, dbSession) {
12790
13050
  const columns = adminColumns(admin.model);
12791
13051
  const search = queryString(req.query.q);
12792
13052
  const sortField = queryString(req.query.sort);
12793
13053
  const sortColumn = sortField in columns ? sortField : null;
12794
- const ascending = sortColumn === null ? admin.orderAscending : queryString(req.query.dir) !== "desc";
13054
+ const lens = admin.getLens(queryString(req.query.lens));
12795
13055
  const conditions = [];
13056
+ if (lens !== null && Object.keys(lens.filters).length > 0) {
13057
+ conditions.push(lens.filters);
13058
+ }
12796
13059
  const filterViews = [];
12797
13060
  for (const field of admin.listFilter) {
12798
13061
  const column6 = columns[field];
@@ -12856,15 +13119,21 @@ function makeAdminRouter(site, options) {
12856
13119
  baseQuery[`filter_${view.field}`] = view.value;
12857
13120
  }
12858
13121
  }
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;
12859
13127
  return {
12860
13128
  search,
12861
13129
  searchable,
12862
13130
  where: conditions.length === 0 ? void 0 : tempestDbJs.and(...conditions),
12863
- orderBy: sortColumn ?? admin.orderKey ?? void 0,
13131
+ orderBy: sortColumn ?? lensColumn ?? admin.orderKey ?? void 0,
12864
13132
  ascending,
12865
13133
  sortColumn,
12866
13134
  filterViews,
12867
- baseQuery
13135
+ baseQuery,
13136
+ lens: lens?.slug ?? ""
12868
13137
  };
12869
13138
  }
12870
13139
  async function relatedOptions(column6, dbSession) {
@@ -12891,6 +13160,76 @@ function makeAdminRouter(site, options) {
12891
13160
  }
12892
13161
  return router;
12893
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
+ }
12894
13233
  function bulkActionsFor(admin) {
12895
13234
  const actions = [];
12896
13235
  const hasActiveFlag = "isActive" in adminColumns(admin.model);
@@ -14943,7 +15282,7 @@ async function withTestDatabase(models, fn) {
14943
15282
  }
14944
15283
 
14945
15284
  // src/version.ts
14946
- var VERSION = "0.25.0";
15285
+ var VERSION = "0.26.0";
14947
15286
 
14948
15287
  Object.defineProperty(exports, "OpenAPIRegistry", {
14949
15288
  enumerable: true,
@@ -15101,6 +15440,7 @@ exports.ADMIN_CSS = ADMIN_CSS;
15101
15440
  exports.ActivationService = ActivationService;
15102
15441
  exports.AdminJsonSite = AdminJsonSite;
15103
15442
  exports.AdminModel = AdminModel;
15443
+ exports.AdminPermission = AdminPermission;
15104
15444
  exports.AdminSessionStore = AdminSessionStore;
15105
15445
  exports.AdminSite = AdminSite;
15106
15446
  exports.AppException = AppException;
@@ -15201,6 +15541,7 @@ exports.activationSchema = activationSchema;
15201
15541
  exports.addLogSink = addLogSink;
15202
15542
  exports.adminAction = adminAction;
15203
15543
  exports.adminColumns = adminColumns;
15544
+ exports.adminLens = adminLens;
15204
15545
  exports.adminThemeCss = adminThemeCss;
15205
15546
  exports.attachWebSocketHub = attachWebSocketHub;
15206
15547
  exports.authResponseSchema = authResponseSchema;
@@ -15302,6 +15643,7 @@ exports.makeToolSpecRouter = makeToolSpecRouter;
15302
15643
  exports.makeTwilioWebhookRouter = makeTwilioWebhookRouter;
15303
15644
  exports.makeUnhandledExceptionHandler = makeUnhandledExceptionHandler;
15304
15645
  exports.makeWhatsAppWebhookRouter = makeWhatsAppWebhookRouter;
15646
+ exports.metricCard = metricCard;
15305
15647
  exports.mfaChallengeSchema = mfaChallengeSchema;
15306
15648
  exports.mfaCodeSchema = mfaCodeSchema;
15307
15649
  exports.mfaEnrollResponseSchema = mfaEnrollResponseSchema;
@@ -15326,6 +15668,7 @@ exports.paginationSchema = paginationSchema;
15326
15668
  exports.parseAcceptLanguage = parseAcceptLanguage;
15327
15669
  exports.parseCookies = parseCookies;
15328
15670
  exports.parseFormBody = parseFormBody;
15671
+ exports.partitionTotal = partitionTotal;
15329
15672
  exports.passwordResetConfirmSchema = passwordResetConfirmSchema;
15330
15673
  exports.passwordResetRequestSchema = passwordResetRequestSchema;
15331
15674
  exports.percentField = percentField;
@@ -15378,6 +15721,8 @@ exports.toUtc = toUtc;
15378
15721
  exports.tokenFromUrl = tokenFromUrl;
15379
15722
  exports.tokenPairSchema = tokenPairSchema;
15380
15723
  exports.tokenSettingsShape = tokenSettingsShape;
15724
+ exports.trendDirection = trendDirection;
15725
+ exports.trendPercent = trendPercent;
15381
15726
  exports.ufField = ufField;
15382
15727
  exports.updatedByColumn = updatedByColumn;
15383
15728
  exports.uploadSettingsShape = uploadSettingsShape;