pgkiln 0.35.0 → 0.36.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.
Files changed (76) hide show
  1. package/db/migrations/087_login_page.sql +175 -0
  2. package/db/migrations/088_region_templates.sql +18 -0
  3. package/db/migrations/089_page_groups.sql +330 -0
  4. package/docs/apex-feature-parity.md +22 -20
  5. package/docs/guide/03-builder.md +36 -2
  6. package/docs/guide/04-pages-and-regions.md +42 -3
  7. package/docs/guide/08-security.md +46 -5
  8. package/docs/guide/11-from-apex.md +4 -1
  9. package/docs/guide/12-development.md +7 -1
  10. package/docs/guide/17-mobile.md +3 -1
  11. package/docs/guide/18-cli.md +2 -0
  12. package/docs/guide/20-ai-agents.md +2 -0
  13. package/examples/hr/hr_54_region_templates.sql +67 -0
  14. package/examples/hr/hr_55_page_groups.sql +24 -0
  15. package/package.json +1 -1
  16. package/public/app.css +60 -3
  17. package/public/app.js +89 -3
  18. package/public/builder.css +11 -0
  19. package/public/builder.js +50 -0
  20. package/public/code-editor.css +3 -3
  21. package/public/sw.js +5 -3
  22. package/src/app.ts +2 -0
  23. package/src/appfiles.ts +1 -0
  24. package/src/builder/advisor.ts +14 -0
  25. package/src/builder/components.ts +15 -2
  26. package/src/builder/ddl-tools.ts +114 -0
  27. package/src/builder/designer.ts +29 -3
  28. package/src/builder/forms.ts +6 -4
  29. package/src/builder/login-page.ts +82 -0
  30. package/src/builder/region-help.ts +4 -2
  31. package/src/builder/region-settings.ts +19 -3
  32. package/src/builder/routes.ts +16 -4
  33. package/src/builder/shared.ts +1 -1
  34. package/src/builder/sql.ts +3 -0
  35. package/src/builder/ui.ts +2 -2
  36. package/src/cli/main.ts +45 -0
  37. package/src/cli/mcp.ts +42 -0
  38. package/src/cli/replace.ts +4 -0
  39. package/src/ddl.ts +509 -0
  40. package/src/i18n/ar.ts +17 -0
  41. package/src/i18n/cs.ts +17 -0
  42. package/src/i18n/da.ts +17 -0
  43. package/src/i18n/de.ts +17 -0
  44. package/src/i18n/el.ts +17 -0
  45. package/src/i18n/es.ts +17 -0
  46. package/src/i18n/fi.ts +17 -0
  47. package/src/i18n/fr.ts +17 -0
  48. package/src/i18n/he.ts +17 -0
  49. package/src/i18n/it.ts +17 -0
  50. package/src/i18n/ja.ts +17 -0
  51. package/src/i18n/ko.ts +17 -0
  52. package/src/i18n/nb.ts +17 -0
  53. package/src/i18n/pl.ts +17 -0
  54. package/src/i18n/pt.ts +17 -0
  55. package/src/i18n/ru.ts +17 -0
  56. package/src/i18n/sv.ts +17 -0
  57. package/src/i18n/tr.ts +17 -0
  58. package/src/i18n/uk.ts +17 -0
  59. package/src/i18n/zh.ts +17 -0
  60. package/src/i18n.ts +34 -0
  61. package/src/icons.ts +2 -1
  62. package/src/layout.ts +3 -2
  63. package/src/metadata.ts +10 -4
  64. package/src/runtime/devbar.ts +3 -1
  65. package/src/runtime/display-selector.ts +12 -4
  66. package/src/runtime/grid.ts +170 -26
  67. package/src/runtime/lists.ts +11 -1
  68. package/src/runtime/password-reset.ts +171 -0
  69. package/src/runtime/pwa.ts +7 -10
  70. package/src/runtime/regions.ts +33 -0
  71. package/src/runtime/render.ts +15 -0
  72. package/src/runtime/report-views.ts +4 -2
  73. package/src/runtime/report.ts +36 -22
  74. package/src/runtime/routes.ts +35 -32
  75. package/src/runtime/signin.ts +80 -0
  76. package/src/static-version.ts +34 -0
@@ -12,7 +12,8 @@ import { lovContains, lovOptions, type LovOption } from './items.ts';
12
12
  import { resolveRestRegion, restAllows, restGridCall, restWritable } from './rest-sources.ts';
13
13
  import { fillItems, linkAttrs } from './links.ts';
14
14
  import { detailsOf, masterColumnOf, masterItemOf, selectHref, selectRowOf } from './master-detail.ts';
15
- import { aggregateRows, AGGREGATES, buildSql, cell, columnFormats, headingOf, key, pageInfo, pagerNav, regionUrl, reportParams, reportState, visibleColumns, type ReportState } from './report.ts';
15
+ import { aggregateRows, AGGREGATES, buildSql, cell, chartSection, columnFormats, groupBySection, headingOf, key, MAX_PAGE, pageInfo, pagerNav, regionUrl, reportParams, reportState, visibleColumns, type ReportState } from './report.ts';
16
+ import { GRID_VIEWS, renderView, type View } from './report-views.ts';
16
17
 
17
18
  // Interactive grid: an editable report on one table (APEX's Interactive
18
19
  // Grid). The region's SELECT must include the table's primary key column.
@@ -27,7 +28,15 @@ import { aggregateRows, AGGREGATES, buildSql, cell, columnFormats, headingOf, ke
27
28
  // "delete": true, "links": [{"label": "Reviews", "page": 20, "items": {...}}]},
28
29
  // "select_row": {"column": "deptno", "item": "P27_DEPTNO"}, (a master grid, see master-detail.ts)
29
30
  // "master": {"item": "P27_DEPTNO", "column": "deptno"}, (a detail grid)
30
- // "actions": true, "saved_reports": true, "public_reports": "ADMIN"}
31
+ // "actions": true, "saved_reports": true, "public_reports": "ADMIN",
32
+ // "views": ["row", "group", "chart"]}
33
+ //
34
+ // Views (APEX: Single Row View, Group By, Chart), ?r<id>_v=row|group|chart:
35
+ // the single row view shows one row of the search as a form (r<id>_row=N, the
36
+ // position from 1, or "new" for a new row) with the grid's own field names,
37
+ // so the same grid_dml process saves it; group by and chart are the
38
+ // interactive report's views (report-views.ts). config.views limits them
39
+ // (left out: all three).
31
40
  //
32
41
  // Aggregates are computed in the database over every row of the search (not
33
42
  // only the page). The layout is the developer's default; each user arranges
@@ -145,20 +154,47 @@ function rowActionsOf(r: Region): { edit: RowLink | null; duplicate: boolean; de
145
154
  };
146
155
  }
147
156
 
157
+ /** The views a user may switch to besides the grid itself (config.views; left out: all three). */
158
+ export function gridViews(r: Region): View[] {
159
+ const listed = r.config.views;
160
+ const all = GRID_VIEWS.filter((v) => v !== 'report');
161
+ return Array.isArray(listed) ? all.filter((v) => listed.includes(v)) : all;
162
+ }
163
+
148
164
  export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
149
165
  const c = ctx.client!;
150
166
  const t = ctx.locale.t;
151
167
  const tr = ctx.locale.tr;
152
168
  if (!(r.table_name || r.rest_source) || !r.pk_column) return html`<div class="alert alert-error">Grid regions need a table (or a REST data source with key columns) and a primary key column.</div>`;
153
169
  const base = reportState(ctx, r);
154
- const st = { ...base, size: Math.max(1, Math.min(200, Number(r.config.page_size) || 25)), breakCol: null, view: 'report' as const };
170
+ const views = gridViews(r);
171
+ const view: View = views.includes(base.view) ? base.view : 'report';
172
+ const pageSize = Math.max(1, Math.min(200, Number(r.config.page_size) || 25));
173
+ // the single row view: one row of the search at a time (r<id>_row=N from 1), or a new row (r<id>_row=new)
174
+ const rowParam = ctx.params.get(key(r, 'row'));
175
+ const adding = view === 'row' && rowParam === 'new';
176
+ const rowNo = view === 'row' && !adding ? Math.max(1, Math.min(MAX_PAGE, parseInt(rowParam ?? '1', 10) || 1)) : 0;
177
+ const st = view === 'row'
178
+ ? { ...base, size: 1, page: rowNo || 1, breakCol: null, view: 'report' as const }
179
+ : { ...base, size: pageSize, breakCol: null, view: 'report' as const };
155
180
  const aggs = gridAggregates(r, st);
156
181
  let res: pg.QueryResult<any[]>;
157
182
  let writable: Set<string>;
158
183
  let agg: Awaited<ReturnType<typeof aggregateRows>> = null;
159
184
  try {
160
185
  writable = await writableColumns(c, r);
161
- res = await savepoint(c, async () => c.query({ ...(await buildSql(ctx, r, st, 'page')), rowMode: 'array' }));
186
+ const query = async () => savepoint(c, async () => c.query({ ...(await buildSql(ctx, r, st, 'page')), rowMode: 'array' }));
187
+ res = await query();
188
+ // a position past the end (rows deleted, a narrower search): the last row
189
+ if (view === 'row' && !res.rows.length && st.page > 1) {
190
+ st.page = 1;
191
+ res = await query();
192
+ const total = Number(res.rows[0]?.[res.fields.findIndex((f) => f.name === '__total')]);
193
+ if (Number.isFinite(total) && total > 1) {
194
+ st.page = Math.min(total, MAX_PAGE);
195
+ res = await query();
196
+ }
197
+ }
162
198
  } catch (e) {
163
199
  return html`<div class="alert alert-error" role="alert">${await publicError(ctx, e, `grid "${r.title ?? r.id}"`)}</div>`;
164
200
  }
@@ -167,7 +203,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
167
203
  const info = pageInfo(r, st, res.rows, totalIdx);
168
204
  const pkIdx = fields.findIndex((f) => f.name === r.pk_column);
169
205
  if (pkIdx === -1) return html`<div class="alert alert-error">The grid query must select the primary key column ${r.pk_column}.</div>`;
170
- if (aggs.length)
206
+ if (aggs.length && view === 'report')
171
207
  try {
172
208
  agg = await aggregateRows(ctx, r, { ...st, aggregates: aggs }, (col) => NUMERIC.has(fields.find((f) => f.name === col)?.dataTypeID ?? 0));
173
209
  } catch (e) {
@@ -248,6 +284,13 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
248
284
  : html`<th scope="col" class="${leadCls(k)}"><span class="sr-only">${t('grid.row_actions')}</span></th>`,
249
285
  );
250
286
 
287
+ // the single row view of a position in the search (from 1), or of a new row
288
+ const rowUrl = (n: number | 'new') => regionUrl(ctx, r, (p) => {
289
+ p.set(key(r, 'v'), 'row');
290
+ p.set(key(r, 'row'), String(n));
291
+ for (const k of ['p', 'k', 'dup']) p.delete(key(r, k));
292
+ });
293
+
251
294
  const rowItems = (row: unknown[], items: Record<string, string> | undefined) =>
252
295
  fillItems(items, (col) => {
253
296
  const i = fields.findIndex((f) => f.name.toLowerCase() === col.toLowerCase());
@@ -284,6 +327,7 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
284
327
  ${editLink ? html`<a ${linkAttrs(ctx, editLink.page, rowItems(row, editLink.items))}>${icon('edit')} ${editLink.label ? tr(editLink.label) : t('report.edit')}</a>` : ''}
285
328
  ${canDuplicate ? html`<a href="${regionUrl(ctx, r, (q) => q.set(key(r, 'dup'), pk))}" data-grid-dup="${i}" data-grid-leave>${icon('plus')} ${t('grid.duplicate')}</a>` : ''}
286
329
  ${canDelete ? html`<label for="${g}_${i}_del" data-grid-del>${icon('close')} ${t('grid.delete_toggle')}</label>` : ''}
330
+ ${views.includes('row') ? html`<a href="${rowUrl(info.from + i)}" data-grid-leave>${t('grid.view_row')}</a>` : ''}
287
331
  ${extraLinks.map((l) => html`<a ${linkAttrs(ctx, l.page, rowItems(row, l.items))}>${tr(l.label!)}</a>`)}
288
332
  </div></details></td>`;
289
333
  });
@@ -326,6 +370,62 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
326
370
  postedNew.push(newRow(nextNew++, values));
327
371
  }
328
372
 
373
+ // The single row view: one row as a form, with the grid's field names (row 0, or new row 0)
374
+ const field = (p: Placed<(typeof cols)[number]>, control: Raw | string, editable: boolean) => {
375
+ const lbl = html`${label(p.col.name)}${p.col.required && editable ? html`<span class="req" aria-hidden="true">*</span>` : ''}`;
376
+ return editable
377
+ ? html`<label class="field grid-field"${hiddenAttr(p)}><span class="label">${lbl}</span>${control}</label>`
378
+ : html`<div class="field grid-field"${hiddenAttr(p)}><span class="label">${lbl}</span><div class="grid-value">${control}</div></div>`;
379
+ };
380
+ const recordNav = () => {
381
+ const total = info.total;
382
+ const at = info.from;
383
+ const link = (n: number, text: string, rel = '') => html`<a class="btn" href="${rowUrl(n)}"${rel ? raw(` rel="${rel}"`) : ''} data-grid-leave>${text}</a>`;
384
+ return html`<nav class="pager grid-record-nav" aria-label="${t('report.pagination')}">
385
+ ${at > 1 || adding ? link(1, `« ${t('grid.first')}`) : ''}
386
+ ${at > 1 ? link(at - 1, `‹ ${t('report.previous')}`, 'prev') : ''}
387
+ <span>${adding ? t('grid.new_row') : total !== null ? t('grid.row_of', { row: at, total }) : t('grid.row', { row: at })}</span>
388
+ ${!adding && info.more ? link(at + 1, `${t('report.next')} ›`, 'next') : ''}
389
+ ${total !== null && total > 0 && (adding || at < total) ? link(total, `${t('grid.last')} »`) : ''}
390
+ </nav>`;
391
+ };
392
+ const record = (row: unknown[]) => {
393
+ const pk = toState(row[pkIdx]) ?? '';
394
+ const pi = postedByPk.get(pk);
395
+ const orig: Record<string, string> = {};
396
+ const fieldsHtml = placed.map((p) => {
397
+ const col = p.col;
398
+ const dbValue = toState(row[col.idx]) ?? '';
399
+ orig[col.ci] = dbValue;
400
+ if (!col.editable) return field(p, cell(row[col.idx], col.typeOid, fmtOf(col.name)), false);
401
+ const value = pi !== undefined ? (col.typeOid === 16 ? (pv(`${g}_${pi}_c${col.ci}`) === 'true' ? 'true' : 'false') : (pv(`${g}_${pi}_c${col.ci}`) ?? dbValue)) : dbValue;
402
+ return field(p, control(col, `${g}_0_c${col.ci}`, value, label(col.name)), true);
403
+ });
404
+ const deleted = pi !== undefined && pv(`${g}_${pi}_del`) === 'true';
405
+ const links = [
406
+ editLink ? html`<a class="btn" ${linkAttrs(ctx, editLink.page, rowItems(row, editLink.items))}>${icon('edit')} ${editLink.label ? tr(editLink.label) : t('report.edit')}</a>` : '',
407
+ ...extraLinks.map((l) => html`<a class="btn" ${linkAttrs(ctx, l.page, rowItems(row, l.items))}>${tr(l.label!)}</a>`),
408
+ ];
409
+ return html`<div class="grid-record${deleted ? ' deleted' : ''}" data-row="0">
410
+ <div class="form-grid">${fieldsHtml}</div>
411
+ ${deletable ? html`<label class="check grid-sel"><input type="checkbox" name="${g}_0_del" value="true"${deleted ? raw(' checked') : ''}> ${t('grid.delete_toggle')}</label>` : ''}
412
+ ${links.some(Boolean) ? html`<div class="buttons">${links}</div>` : ''}
413
+ ${savable ? html`<input type="hidden" name="${g}_0_pk" value="${pk}"><input type="hidden" name="${g}_0_cs" value="${rowToken(ctx, r, pk)}"><input type="hidden" name="${g}_0_orig" value="${JSON.stringify(orig)}">` : ''}
414
+ </div>
415
+ ${recordNav()}`;
416
+ };
417
+ const newRecord = () => {
418
+ if (!insertable) return html`<p class="empty">${t('report.no_data')}</p>`;
419
+ const fieldsHtml = placed.flatMap((p) => {
420
+ const col = p.col;
421
+ if (masterCol && col.name === masterCol) return [field(p, masterValue, false)];
422
+ if (!writable.has(col.name)) return [];
423
+ return [field(p, control({ ...col, editable: true }, `${g}_n0_c${col.ci}`, pv(`${g}_n0_c${col.ci}`) ?? '', label(col.name)), true)];
424
+ });
425
+ return html`<div class="grid-record grid-new" data-new-row="0"><div class="form-grid">${fieldsHtml}</div></div>
426
+ ${recordNav()}`;
427
+ };
428
+
329
429
  // the aggregates under their columns (over all rows of the search, not only this page)
330
430
  const footer = agg && cols.length
331
431
  ? (() => {
@@ -344,10 +444,50 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
344
444
  const searchForm = `rs${r.id}`;
345
445
  ctx.detached.push(
346
446
  html`<form id="${searchForm}" method="get" action="${ctx.base}/${ctx.page.page_no}">${[...ctx.params.entries()]
347
- .filter(([k]) => ![key(r, 'q'), key(r, 'p'), key(r, 'dup'), 'clear', 'cs'].includes(k))
447
+ .filter(([k]) => ![key(r, 'q'), key(r, 'p'), key(r, 'dup'), key(r, 'row'), 'clear', 'cs'].includes(k))
348
448
  .map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}</form>`,
349
449
  );
350
- const menu = r.config.actions === false ? '' : await actionsMenu(ctx, r, cols, placed, layout, ownLayout, aggs);
450
+ const menu = r.config.actions === false ? '' : await actionsMenu(ctx, r, base, cols, placed, layout, ownLayout, aggs, views, view === 'row' ? null : rowUrl(info.from || 1));
451
+
452
+ // Grid / Single row / Group by / Chart: once the user has set up group by or chart, or is in the single
453
+ // row view (opened from the Actions or row actions menu), so a plain grid looks as it always did
454
+ const shownViews = GRID_VIEWS.filter((v) => v === 'report' || v === view || (views.includes(v) && ((v === 'group' && base.groupBy.columns.length) || (v === 'chart' && base.chart))));
455
+ const switcher = shownViews.length > 1
456
+ ? html`<nav class="seg view-switch" aria-label="${t('report.view')}">${shownViews.map((v) => html`<a href="${v === 'row' ? rowUrl(view === 'report' ? info.from || 1 : 1) : regionUrl(ctx, r, (p) => {
457
+ if (v === 'report') p.delete(key(r, 'v'));
458
+ else p.set(key(r, 'v'), v);
459
+ p.delete(key(r, 'row'));
460
+ // back to the grid: the page with the row the single row view showed
461
+ if (v === 'report' && view === 'row' && info.from) p.set(key(r, 'p'), String(Math.ceil(info.from / pageSize)));
462
+ else p.delete(key(r, 'p'));
463
+ })}" data-grid-leave${v === view ? raw(' aria-current="true"') : ''}>${t(v === 'report' ? 'grid.view_grid' : v === 'row' ? 'grid.view_row' : `report.view_${v}`)}</a>`)}</nav>`
464
+ : '';
465
+ const toolbar = (buttons: Raw | '') => html`<div class="report-toolbar grid-toolbar">
466
+ <div class="search" role="search">
467
+ <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_placeholder')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}">
468
+ <button class="btn" form="${searchForm}">${t('report.go')}</button>
469
+ ${menu}
470
+ </div>
471
+ <div class="buttons">${buttons}</div>
472
+ </div>`;
473
+ // Save posts the page's report and grid state along, so a failed save shows the same view and a
474
+ // successful one comes back to it (routes.ts: gridReturn)
475
+ const saveBtn = () => {
476
+ const state = statePart(ctx).toString();
477
+ return html`<button type="submit" class="btn btn-hot" name="__request" value="${saveRequest(r)}"${state ? html` formaction="${ctx.base}/${ctx.page.page_no}?${state}"` : ''}>${t('grid.save')}</button>`;
478
+ };
479
+ const gridAttrs = raw(`${details.length ? ` data-grid-details="${details.map((d) => d.id).join(',')}"` : ''}`);
480
+ if (view === 'group' || view === 'chart')
481
+ return html`<div class="grid grid-view" data-grid="${g}" data-region="${r.id}"${gridAttrs}>
482
+ ${toolbar('')}${switcher}${await renderView(ctx, r, { ...base, view })}
483
+ </div>`;
484
+ if (view === 'row')
485
+ return html`<div class="grid grid-view" data-grid="${g}" data-region="${r.id}"${gridAttrs}>
486
+ ${toolbar(html`${insertable && !adding ? html`<a class="btn" href="${rowUrl('new')}" data-grid-leave>${icon('plus')} ${t('grid.add_row')}</a>` : ''}
487
+ ${savable && (adding ? insertable : info.rows.length) ? saveBtn() : ''}`)}
488
+ ${switcher}
489
+ ${adding ? newRecord() : info.rows.length ? record(info.rows[0]) : html`<p class="empty">${r.config.empty ?? t('report.no_data')}</p>`}
490
+ </div>`;
351
491
  const chips = aggs.filter((a) => a.own).map((a) => html`<span class="chip">${t(`agg.${a.fn}`)}: <b>${label(a.column)}</b>
352
492
  <a href="${regionUrl(ctx, r, (p) => {
353
493
  const rest = p.getAll(key(r, 'a')).filter((x) => x !== a.raw);
@@ -355,18 +495,10 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
355
495
  rest.forEach((x) => p.append(key(r, 'a'), x));
356
496
  })}" aria-label="${t('report.remove')}" data-grid-leave>×</a></span>`);
357
497
 
358
- return html`<div class="grid${frozenAny ? ' grid-has-frozen' : ''}" data-grid="${g}" data-region="${r.id}"${r.config.actions === false ? '' : raw(' data-arrange')}${details.length ? raw(` data-grid-details="${details.map((d) => d.id).join(',')}"`) : ''}>
359
- <div class="report-toolbar grid-toolbar">
360
- <div class="search" role="search">
361
- <input type="search" name="${key(r, 'q')}" value="${st.search}" placeholder="${t('report.search_placeholder')}" form="${searchForm}" aria-label="${t('report.search')} ${r.title ?? ''}">
362
- <button class="btn" form="${searchForm}">${t('report.go')}</button>
363
- ${menu}
364
- </div>
365
- <div class="buttons">
366
- ${insertable ? html`<button type="button" class="btn" data-grid-add="${g}">${icon('plus')} ${t('grid.add_row')}</button>` : ''}
367
- ${savable ? html`<button type="submit" class="btn btn-hot" name="__request" value="${saveRequest(r)}">${t('grid.save')}</button>` : ''}
368
- </div>
369
- </div>
498
+ return html`<div class="grid${frozenAny ? ' grid-has-frozen' : ''}" data-grid="${g}" data-region="${r.id}"${r.config.actions === false ? '' : raw(' data-arrange')}${gridAttrs}>
499
+ ${toolbar(html`${insertable ? html`<button type="button" class="btn" data-grid-add="${g}">${icon('plus')} ${t('grid.add_row')}</button>` : ''}
500
+ ${savable ? saveBtn() : ''}`)}
501
+ ${switcher}
370
502
  ${chips.length ? html`<div class="chips">${chips}</div>` : ''}
371
503
  <div class="table-wrap"><table class="report report-reflow grid-table" data-frozen="${placed.filter((p) => p.frozen).length}" data-leads="${leads.length}">
372
504
  <thead><tr>${leadHeads}${header}</tr></thead>
@@ -380,8 +512,8 @@ export async function renderGrid(ctx: PageContext, r: Region): Promise<Raw> {
380
512
 
381
513
  /** The grid's Actions menu: columns (order, shown, width, frozen), aggregates, saved reports, reset. */
382
514
  async function actionsMenu(
383
- ctx: PageContext, r: Region, cols: (GridColumn & { ci: number })[], placed: Placed<GridColumn & { ci: number }>[],
384
- layout: GridLayout, ownLayout: boolean, aggs: ReturnType<typeof gridAggregates>,
515
+ ctx: PageContext, r: Region, st: ReportState, cols: (GridColumn & { ci: number })[], placed: Placed<GridColumn & { ci: number }>[],
516
+ layout: GridLayout, ownLayout: boolean, aggs: ReturnType<typeof gridAggregates>, views: View[], singleRow: string | null,
385
517
  ) {
386
518
  const t = ctx.locale.t;
387
519
  const label = (name: string) => headingOf(r, name, ctx.locale.tr);
@@ -391,13 +523,17 @@ async function actionsMenu(
391
523
  const layoutForm = `rl${r.id}`;
392
524
  const resetForm = `rr${r.id}`;
393
525
  const aggForm = `ra${r.id}`;
526
+ const groupForm = `rg${r.id}`;
527
+ const chartForm = `rk${r.id}`;
394
528
  ctx.detached.push(html`<form id="${layoutForm}" method="post" action="${base}/layout">${csrf}<input type="hidden" name="params" value="${here}"><input type="hidden" name="n" value="${placed.length}"></form>`);
395
529
  ctx.detached.push(html`<form id="${resetForm}" method="post" action="${base}/layout/reset">${csrf}<input type="hidden" name="params" value="${here}"></form>`);
396
- ctx.detached.push(
397
- html`<form id="${aggForm}" method="get" action="${ctx.base}/${ctx.page.page_no}">${[...ctx.params.entries()]
398
- .filter(([k]) => ![key(r, 'p'), key(r, 'dup'), 'clear', 'cs'].includes(k))
399
- .map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}</form>`,
400
- );
530
+ // Aggregate, Group by and Chart: GET forms that keep the rest of the page's state
531
+ for (const id of [aggForm, ...(views.includes('group') ? [groupForm] : []), ...(views.includes('chart') ? [chartForm] : [])])
532
+ ctx.detached.push(
533
+ html`<form id="${id}" method="get" action="${ctx.base}/${ctx.page.page_no}">${[...ctx.params.entries()]
534
+ .filter(([k]) => ![key(r, 'p'), key(r, 'dup'), key(r, 'row'), 'clear', 'cs'].includes(k))
535
+ .map(([k, v]) => html`<input type="hidden" name="${k}" value="${v}">`)}</form>`,
536
+ );
401
537
  const columnRows = placed.map((p, i) => html`<tr>
402
538
  <th scope="row">${label(p.col.name)}<input type="hidden" name="col_${i}" value="${p.col.name}" form="${layoutForm}"></th>
403
539
  <td><input type="checkbox" name="show_${i}" value="true" form="${layoutForm}"${p.hidden ? '' : raw(' checked')} aria-label="${t('grid.show_column', { column: label(p.col.name) })}"></td>
@@ -431,6 +567,9 @@ async function actionsMenu(
431
567
  </div>
432
568
  ${aggs.some((a) => !a.own) ? html`<small class="help">${t('grid.default_aggregates', { list: aggs.filter((a) => !a.own).map((a) => `${t(`agg.${a.fn}`)} ${label(a.column)}`).join(', ') })}</small>` : ''}
433
569
  </div>
570
+ ${views.includes('row') && singleRow ? html`<div class="menu-section menu-links"><a href="${singleRow}" data-grid-leave>${t('grid.view_row')}</a></div>` : ''}
571
+ ${views.includes('group') ? groupBySection(ctx, r, st, cols.map((c) => c.name), groupForm) : ''}
572
+ ${views.includes('chart') ? chartSection(ctx, r, st, cols.map((c) => c.name), chartForm) : ''}
434
573
  ${saved ?? ''}
435
574
  </div>
436
575
  </details>`;
@@ -447,6 +586,11 @@ export function statePart(ctx: PageContext, drop?: Region) {
447
586
  return out;
448
587
  }
449
588
 
589
+ /** After a grid's Save: the report and grid parameters the request came with (the formaction), for the redirect. */
590
+ export function gridReturn(ctx: PageContext, request: string) {
591
+ return /^GRID_SAVE_\d+$/.test(request) ? statePart(ctx).toString() : '';
592
+ }
593
+
450
594
  /** Actions → Saved reports of a grid: its parameters plus the column layout (r<id>_lay). */
451
595
  async function gridSavedReports(ctx: PageContext, r: Region, layout: GridLayout) {
452
596
  if (ctx.user === 'nobody' || r.config.saved_reports === false) return null;
@@ -36,7 +36,7 @@ export interface ListNode {
36
36
  children: ListNode[];
37
37
  }
38
38
 
39
- export const LIST_TEMPLATES = ['links', 'badges', 'cards', 'tabs'] as const;
39
+ export const LIST_TEMPLATES = ['links', 'badges', 'cards', 'tabs', 'wizard_progress'] as const;
40
40
  export type ListTemplate = (typeof LIST_TEMPLATES)[number];
41
41
  /** Most rows a list query may give. */
42
42
  export const LIST_MAX_ROWS = 500;
@@ -228,6 +228,16 @@ export async function renderListRegion(ctx: PageContext, r: Region): Promise<Raw
228
228
  return html`<ul class="cards list-cards">${flat(nodes).map((n) => html`<li><a ${linkAttrs(n, 'card list-card')}><span class="list-card-head">${icon(n.icon ?? 'chevron')}<h3>${n.label}</h3>${badge(n)}</span>${n.description ? html`<p class="muted">${n.description}</p>` : ''}</a></li>`)}</ul>`;
229
229
  case 'tabs':
230
230
  return html`<nav class="list-tabs" aria-label="${label}"><ul class="rds-list">${nodes.filter((n) => n.href).map((n) => html`<li><a ${linkAttrs(n, `rds-tab${n.inTrail ? ' is-current' : ''}`)}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
231
+ case 'wizard_progress': {
232
+ // (088) the steps of a wizard (APEX: Wizard Progress): the current one (the page in the trail), the ones before it done
233
+ const steps = flat(nodes);
234
+ const at = steps.findIndex((n) => n.inTrail);
235
+ return html`<nav class="wizard-progress" aria-label="${label}"><ol>${steps.map((n, i) => {
236
+ const state = i === at ? 'current' : at >= 0 && i < at ? 'done' : 'todo';
237
+ const text = html`<span class="wizard-step-no" aria-hidden="true">${state === 'done' ? icon('check') : i + 1}</span><span class="wizard-step-label">${n.label}</span>`;
238
+ return html`<li class="wizard-step is-${state}"${state === 'current' ? html` aria-current="step"` : ''}>${state === 'done' && n.href ? html`<a ${linkAttrs(n, '')}>${text}</a>` : text}</li>`;
239
+ })}</ol></nav>`;
240
+ }
231
241
  default:
232
242
  return html`<nav aria-label="${label}">${linksMarkup(nodes)}</nav>`;
233
243
  }
@@ -0,0 +1,171 @@
1
+ import { createHash, randomBytes } from 'node:crypto';
2
+ import type { FastifyInstance } from 'fastify';
3
+ import { passwordProblem } from '../accounts.ts';
4
+ import { runtime } from '../db.ts';
5
+ import { html, type Raw } from '../html.ts';
6
+ import { documentShell } from '../layout.ts';
7
+ import type { App } from '../metadata.ts';
8
+ import { clientIp, getSession, logActivity, saveState, type Session } from '../session.ts';
9
+ import { publicUrl } from '../sso.ts';
10
+ import type { Locale } from './locale.ts';
11
+ import { pwaHead } from './pwa.ts';
12
+ import { appWithLocale, rootAttrs, simplePage, type Req } from './routes.ts';
13
+ import { passwordResetAvailable } from './signin.ts';
14
+
15
+ // "Forgot password?" for app users (migration 087). The request answers the
16
+ // same whether or not an account matched; the database keeps only a SHA-256
17
+ // hash of the token and queues the mail through the application's mail
18
+ // service (meta.request_password_reset). The link is single-use and expires
19
+ // after 30 minutes; opening it moves the token into the session and drops
20
+ // it from the address bar. Setting the new password ends every session and
21
+ // "keep me signed in" token of the account.
22
+ //
23
+ // GET/POST /a/<alias>/password/forgot ask for a link
24
+ // GET /a/<alias>/password/reset?token=… open the link
25
+ // GET/POST /a/<alias>/password/reset choose the new password
26
+
27
+ const RESET = '__RESET';
28
+ /** Requests per address and window, whatever was typed in (the database also allows 3 per account per hour). */
29
+ const MAX_PER_IP = 5;
30
+ const WINDOW_MINUTES = 15;
31
+
32
+ const sha256 = (s: string) => createHash('sha256').update(s).digest('hex');
33
+
34
+ function card(app: App, locale: Locale, title: string, body: Raw) {
35
+ return documentShell(
36
+ `${title} · ${app.name}`,
37
+ html`<main class="login">
38
+ <div class="card login-card">
39
+ <h1>${title}</h1>
40
+ ${body}
41
+ <a class="login-forgot" href="/a/${app.alias}/login">${locale.t('reset.back')}</a>
42
+ </div>
43
+ </main>`,
44
+ 'login-body',
45
+ {},
46
+ pwaHead(app),
47
+ rootAttrs(locale),
48
+ );
49
+ }
50
+
51
+ const forgotPage = (app: App, locale: Locale, session: Session, message?: Raw) => {
52
+ const t = locale.t;
53
+ return card(app, locale, t('reset.title'), html`${message ?? html`<p class="muted">${t('reset.intro')}</p>`}
54
+ <form method="post" action="/a/${app.alias}/password/forgot" class="login-form">
55
+ <input type="hidden" name="__csrf" value="${session.csrf_token}">
56
+ <div class="field"><label class="label" for="login">${t('reset.login_label')}</label><input id="login" name="login" autocomplete="username" autofocus required maxlength="200"></div>
57
+ <button class="btn btn-hot">${t('reset.send')}</button>
58
+ </form>`);
59
+ };
60
+
61
+ const newPasswordPage = (app: App, locale: Locale, session: Session, username: string, error?: string) => {
62
+ const t = locale.t;
63
+ return card(app, locale, t('reset.new_title'), html`${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
64
+ <form method="post" action="/a/${app.alias}/password/reset" class="login-form">
65
+ <input type="hidden" name="__csrf" value="${session.csrf_token}">
66
+ <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${username}" autocomplete="username" readonly></div>
67
+ <div class="field"><label class="label" for="new_password">${t('password.new')}</label><input id="new_password" name="new_password" type="password" autocomplete="new-password" required maxlength="200" autofocus></div>
68
+ <div class="field"><label class="label" for="confirm_password">${t('password.confirm')}</label><input id="confirm_password" name="confirm_password" type="password" autocomplete="new-password" required maxlength="200"></div>
69
+ <button class="btn btn-hot">${t('password.change')}</button>
70
+ </form>`);
71
+ };
72
+
73
+ /** The mail: plain text, and the same as HTML with the link as a link. #USER# is filled in by the database. */
74
+ function resetMail(app: App, locale: Locale, link: string) {
75
+ const t = locale.t;
76
+ const text = t('reset.mail_body', { app: app.name, link });
77
+ const esc = (s: string) => s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
78
+ const body = text.split(/\n{2,}/).map((para) => `<p>${esc(para).replace(esc(link), `<a href="${esc(link)}">${esc(link)}</a>`).replace(/\n/g, '<br>')}</p>`).join('\n');
79
+ return { subject: t('reset.mail_subject', { app: app.name }), text, html: `<!doctype html><html><body>${body}</body></html>` };
80
+ }
81
+
82
+ export function passwordResetRoutes(app: FastifyInstance) {
83
+ /** The application (with its locale) and session, when it offers password resets; else a 404 page. */
84
+ const start = async (req: Req, reply: Parameters<typeof simplePage>[0]) => {
85
+ const loaded = await appWithLocale(req, req.params.alias);
86
+ if (!loaded || !(await passwordResetAvailable(loaded.app))) {
87
+ simplePage(reply, 404, (loaded?.locale.t ?? ((k: string) => k))('error.not_found'), loaded ? loaded.locale.t('login.method_unavailable') : req.params.alias, loaded ? `/a/${loaded.app.alias}/login` : undefined, loaded?.locale);
88
+ return null;
89
+ }
90
+ const session = await getSession(req, reply, loaded.app.id, `/a/${loaded.app.alias}`);
91
+ return { ...loaded, session };
92
+ };
93
+
94
+ app.get('/a/:alias/password/forgot', async (req: Req, reply) => {
95
+ const s = await start(req, reply);
96
+ if (!s) return;
97
+ const body = forgotPage(s.app, s.locale, s.session);
98
+ await saveState(s.session);
99
+ return reply.type('text/html').send(body);
100
+ });
101
+
102
+ app.post('/a/:alias/password/forgot', async (req: Req, reply) => {
103
+ const s = await start(req, reply);
104
+ if (!s) return;
105
+ const { app: a, locale, session } = s;
106
+ const t = locale.t;
107
+ if (req.body?.__csrf !== session.csrf_token)
108
+ return reply.code(403).type('text/html').send(forgotPage(a, locale, session, html`<div class="alert alert-error" role="alert">${t('login.expired_session')}</div>`));
109
+ const ip = clientIp(req);
110
+ const login = String(req.body?.login ?? '').slice(0, 200).replace(/\0/g, '');
111
+ const recent = await runtime.one<{ n: number }>(
112
+ `select count(*)::int as n from meta.activity_log where app_id = $1 and event = 'password_reset_request' and ip = $2 and at > now() - make_interval(mins => $3)`,
113
+ [a.id, ip, WINDOW_MINUTES],
114
+ );
115
+ if ((recent?.n ?? 0) >= MAX_PER_IP)
116
+ return reply.code(429).type('text/html').send(forgotPage(a, locale, session, html`<div class="alert alert-error" role="alert">${t('login.throttled', { minutes: WINDOW_MINUTES })}</div>`));
117
+ await logActivity({ appId: a.id, username: login.slice(0, 100), event: 'password_reset_request', ip });
118
+ if (login.trim()) {
119
+ const token = randomBytes(32).toString('base64url');
120
+ const mail = resetMail(a, locale, `${publicUrl()}/a/${a.alias}/password/reset?token=${token}`);
121
+ await runtime.query('select meta.request_password_reset($1, $2, $3, $4, $5, $6, $7)', [a.id, login, sha256(token), ip, mail.subject, mail.text, mail.html]);
122
+ }
123
+ // the same answer whether or not an account matched
124
+ return reply.type('text/html').send(card(a, locale, t('reset.title'), html`<div class="alert alert-success" role="status">${t('reset.sent')}</div>`));
125
+ });
126
+
127
+ app.get<{ Params: { alias: string }; Querystring: { token?: string } }>('/a/:alias/password/reset', async (req, reply) => {
128
+ const s = await start(req as Req, reply);
129
+ if (!s) return;
130
+ const { app: a, locale, session } = s;
131
+ // the link: keep the token in the session and take it out of the address bar (and history); a redirect
132
+ // without content, so no page with the token in its address loads anything (the Referer stays clean)
133
+ const token = typeof req.query.token === 'string' ? req.query.token.slice(0, 100) : null;
134
+ if (token !== null) {
135
+ session.state[RESET] = sha256(token);
136
+ await saveState(session);
137
+ return reply.redirect(`/a/${a.alias}/password/reset`, 303);
138
+ }
139
+ const hash = session.state[RESET];
140
+ const user = hash ? (await runtime.one<{ u: string | null }>('select meta.password_reset_user($1, $2) as u', [a.id, hash]))?.u : null;
141
+ if (!user) return reply.code(410).type('text/html').send(invalidPage(a, locale));
142
+ return reply.type('text/html').send(newPasswordPage(a, locale, session, user));
143
+ });
144
+
145
+ app.post('/a/:alias/password/reset', async (req: Req, reply) => {
146
+ const s = await start(req, reply);
147
+ if (!s) return;
148
+ const { app: a, locale, session } = s;
149
+ const t = locale.t;
150
+ const hash = session.state[RESET];
151
+ const user = hash ? (await runtime.one<{ u: string | null }>('select meta.password_reset_user($1, $2) as u', [a.id, hash]))?.u : null;
152
+ if (!user) return reply.code(410).type('text/html').send(invalidPage(a, locale));
153
+ const b = req.body ?? {};
154
+ const again = (msg: string, code = 422) => reply.code(code).type('text/html').send(newPasswordPage(a, locale, session, user, msg));
155
+ if (b.__csrf !== session.csrf_token) return again(t('login.expired_session'), 403);
156
+ if (b.new_password !== b.confirm_password) return again(t('password.mismatch'));
157
+ const problem = await passwordProblem(b.new_password, { username: user, t });
158
+ if (problem) return again(problem);
159
+ const done = (await runtime.one<{ u: string | null }>('select meta.reset_password($1, $2, $3) as u', [a.id, hash, String(b.new_password).slice(0, 200)]))?.u;
160
+ if (!done) return reply.code(410).type('text/html').send(invalidPage(a, locale));
161
+ delete session.state[RESET];
162
+ session.state.__FLASH = t('reset.done');
163
+ await saveState(session);
164
+ await logActivity({ appId: a.id, username: done, event: 'password_reset', ip: clientIp(req) });
165
+ return reply.redirect(`/a/${a.alias}/login`, 303);
166
+ });
167
+
168
+ const invalidPage = (a: App, locale: Locale) =>
169
+ card(a, locale, locale.t('reset.new_title'), html`<div class="alert alert-error" role="alert">${locale.t('reset.invalid')}</div>
170
+ <a class="btn" href="/a/${a.alias}/password/forgot">${locale.t('reset.send')}</a>`);
171
+ }
@@ -3,6 +3,7 @@ import { readFileSync } from 'node:fs';
3
3
  import { join } from 'node:path';
4
4
  import { deflateSync } from 'node:zlib';
5
5
  import { root } from '../env.ts';
6
+ import { staticVersion } from '../static-version.ts';
6
7
  import { html, type Raw } from '../html.ts';
7
8
  import { documentShell } from '../layout.ts';
8
9
  import { loadApp, type App } from '../metadata.ts';
@@ -116,7 +117,9 @@ const icons = new Map<string, Buffer>(); // generated tiles by name|colour|size
116
117
 
117
118
  // ------------------------------------------------------------------ routes
118
119
 
119
- const SW = readFileSync(join(root, 'public', 'sw.js'), 'utf8');
120
+ // read again when pgkiln's static files change (static-version.ts)
121
+ let sw = { version: '', text: '' };
122
+ const swText = (version: string) => (sw.version === version ? sw.text : (sw = { version, text: readFileSync(join(root, 'public', 'sw.js'), 'utf8') }).text);
120
123
 
121
124
  export async function pwaRoutes(app: FastifyInstance) {
122
125
  const pwaApp = async (alias: string) => {
@@ -171,12 +174,13 @@ export async function pwaRoutes(app: FastifyInstance) {
171
174
  if (!a) return reply.code(404).send('// not an installable application');
172
175
  const base = `/a/${a.alias}`;
173
176
  // the settings the worker needs, as a JSON literal (no user input in it)
174
- const config = JSON.stringify({ base, offlinePages: a.pwa_offline_pages, offlineSubmit: a.pwa_offline_submit, version: SW_VERSION });
177
+ const version = staticVersion();
178
+ const config = JSON.stringify({ base, offlinePages: a.pwa_offline_pages, offlineSubmit: a.pwa_offline_submit, version });
175
179
  return reply
176
180
  .type('text/javascript; charset=utf-8')
177
181
  .header('cache-control', 'no-cache')
178
182
  .header('service-worker-allowed', `${base}/`)
179
- .send(`const PGKILN = ${config};\n${SW}`);
183
+ .send(`const PGKILN = ${config};\n${swText(version)}`);
180
184
  });
181
185
 
182
186
  app.get<{ Params: { alias: string } }>('/a/:alias/offline', async (req, reply) => {
@@ -205,11 +209,4 @@ export async function pwaRoutes(app: FastifyInstance) {
205
209
  });
206
210
  }
207
211
 
208
- // changes whenever pgkiln's static files change (a new service worker then replaces the old one)
209
- const SW_VERSION = (() => {
210
- let h = 0;
211
- for (const f of ['public/app.css', 'public/app.js', 'public/icons.svg', 'public/sw.js'])
212
- for (const ch of readFileSync(join(root, f), 'utf8')) h = (Math.imul(h, 31) + ch.charCodeAt(0)) | 0;
213
- return (h >>> 0).toString(36);
214
- })();
215
212
 
@@ -33,6 +33,7 @@ import { templateClasses } from './template-options.ts';
33
33
  import { renderPluginRegion } from './plugins.ts';
34
34
  import { renderUrlRegion } from './url-region.ts';
35
35
  import { renderSearch } from './search.ts';
36
+ import { renderLoginRegion } from './signin.ts';
36
37
 
37
38
  // ---------------------------------------------------------------- buttons
38
39
 
@@ -359,6 +360,9 @@ async function renderBody(ctx: PageContext, r: Region, hidden: Set<string>): Pro
359
360
  case 'search':
360
361
  body = await renderSearch(ctx, r);
361
362
  break;
363
+ case 'login':
364
+ body = await renderLoginRegion(ctx);
365
+ break;
362
366
  case 'dynamic':
363
367
  // A SELECT returning HTML (like APEX "PL/SQL Dynamic Content"). The
364
368
  // developer's SQL produces trusted markup; escape data with meta.html_escape().
@@ -395,6 +399,35 @@ async function regionShell(ctx: PageContext, r: Region, hidden: Set<string>, bod
395
399
  ${!buttonsOnTop && buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
396
400
  </section>`;
397
401
 
402
+ // (088) a large banner: an icon, the title, the text and the buttons
403
+ if (r.template === 'hero') {
404
+ const heroIcon = typeof r.config?.icon === 'string' ? icon(r.config.icon) : '';
405
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
406
+ ${heroIcon ? html`<span class="region-hero-icon">${heroIcon}</span>` : ''}
407
+ <div class="region-hero-text"><h2 id="${titleId}">${r.title}</h2>${body}</div>
408
+ ${buttons ? html`<div class="region-hero-buttons">${buttons}</div>` : ''}
409
+ </section>`;
410
+ }
411
+ // (088) the title and buttons on one line, without a card
412
+ if (r.template === 'title_bar')
413
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
414
+ <div class="region-title-bar"><h2 id="${titleId}">${r.title}</h2>${buttons ? html`<div class="region-title-buttons">${buttons}</div>` : ''}</div>
415
+ ${body}
416
+ </section>`;
417
+ // (088) the buttons (and any text) only
418
+ if (r.template === 'buttons_container')
419
+ return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-label="${r.title}">
420
+ ${body}${buttons ? html`<div class="region-buttons">${buttons}</div>` : ''}
421
+ </section>`;
422
+ // (088) hidden until opened: a dynamic action "Open region", or a link to #R<id> (CSS :target without JavaScript)
423
+ if (r.template === 'inline_dialog' || r.template === 'inline_drawer')
424
+ return html`<dialog class="${cls}" id="R${r.id}"${staticAttr} aria-labelledby="${titleId}" data-inline-region>
425
+ <header class="region-header"><h2 id="${titleId}">${r.title}</h2>
426
+ <a class="region-dialog-close icon-button" href="#" role="button" data-region-close aria-label="${ctx.locale.t('common.dismiss')}">${icon('close')}</a></header>
427
+ <div class="region-body">${body}</div>
428
+ ${buttons ? html`<footer class="region-footer">${buttons}</footer>` : ''}
429
+ </dialog>`;
430
+
398
431
  const header = html`<h2 id="${titleId}">${r.title}</h2>${buttonsOnTop ? buttons : ''}`;
399
432
  if (r.template === 'collapsible')
400
433
  return html`<section class="${cls}" id="R${r.id}"${staticAttr}${hiddenAttr} aria-labelledby="${titleId}">
@@ -242,6 +242,21 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
242
242
  'data-dialog': '1',
243
243
  }, html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`, root);
244
244
 
245
+ // (087) a minimal page (a login page, for one): no header and navigation; the application's
246
+ // login page gets the sign-in page's backdrop
247
+ if (ctx.page.page_template === 'minimal') {
248
+ const login = ctx.app.login_page === ctx.page.page_no;
249
+ return documentShell(
250
+ `${title} · ${ctx.app.name}`,
251
+ html`<main class="t-minimal-main${login ? ' login' : ''}" id="main">${main}</main>
252
+ ${devbarHtml(ctx)}`,
253
+ `t-minimal${login ? ' login-body' : ''}`,
254
+ { 'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user) },
255
+ html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
256
+ root,
257
+ );
258
+ }
259
+
245
260
  const signedIn = ctx.user !== 'nobody';
246
261
  const topNav = ctx.app.theme?.nav === 'top';
247
262
  const nav = await navTree(ctx, topNav);