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.
- package/db/migrations/087_login_page.sql +175 -0
- package/db/migrations/088_region_templates.sql +18 -0
- package/db/migrations/089_page_groups.sql +330 -0
- package/docs/apex-feature-parity.md +22 -20
- package/docs/guide/03-builder.md +36 -2
- package/docs/guide/04-pages-and-regions.md +42 -3
- package/docs/guide/08-security.md +46 -5
- package/docs/guide/11-from-apex.md +4 -1
- package/docs/guide/12-development.md +7 -1
- package/docs/guide/17-mobile.md +3 -1
- package/docs/guide/18-cli.md +2 -0
- package/docs/guide/20-ai-agents.md +2 -0
- package/examples/hr/hr_54_region_templates.sql +67 -0
- package/examples/hr/hr_55_page_groups.sql +24 -0
- package/package.json +1 -1
- package/public/app.css +60 -3
- package/public/app.js +89 -3
- package/public/builder.css +11 -0
- package/public/builder.js +50 -0
- package/public/code-editor.css +3 -3
- package/public/sw.js +5 -3
- package/src/app.ts +2 -0
- package/src/appfiles.ts +1 -0
- package/src/builder/advisor.ts +14 -0
- package/src/builder/components.ts +15 -2
- package/src/builder/ddl-tools.ts +114 -0
- package/src/builder/designer.ts +29 -3
- package/src/builder/forms.ts +6 -4
- package/src/builder/login-page.ts +82 -0
- package/src/builder/region-help.ts +4 -2
- package/src/builder/region-settings.ts +19 -3
- package/src/builder/routes.ts +16 -4
- package/src/builder/shared.ts +1 -1
- package/src/builder/sql.ts +3 -0
- package/src/builder/ui.ts +2 -2
- package/src/cli/main.ts +45 -0
- package/src/cli/mcp.ts +42 -0
- package/src/cli/replace.ts +4 -0
- package/src/ddl.ts +509 -0
- package/src/i18n/ar.ts +17 -0
- package/src/i18n/cs.ts +17 -0
- package/src/i18n/da.ts +17 -0
- package/src/i18n/de.ts +17 -0
- package/src/i18n/el.ts +17 -0
- package/src/i18n/es.ts +17 -0
- package/src/i18n/fi.ts +17 -0
- package/src/i18n/fr.ts +17 -0
- package/src/i18n/he.ts +17 -0
- package/src/i18n/it.ts +17 -0
- package/src/i18n/ja.ts +17 -0
- package/src/i18n/ko.ts +17 -0
- package/src/i18n/nb.ts +17 -0
- package/src/i18n/pl.ts +17 -0
- package/src/i18n/pt.ts +17 -0
- package/src/i18n/ru.ts +17 -0
- package/src/i18n/sv.ts +17 -0
- package/src/i18n/tr.ts +17 -0
- package/src/i18n/uk.ts +17 -0
- package/src/i18n/zh.ts +17 -0
- package/src/i18n.ts +34 -0
- package/src/icons.ts +2 -1
- package/src/layout.ts +3 -2
- package/src/metadata.ts +10 -4
- package/src/runtime/devbar.ts +3 -1
- package/src/runtime/display-selector.ts +12 -4
- package/src/runtime/grid.ts +170 -26
- package/src/runtime/lists.ts +11 -1
- package/src/runtime/password-reset.ts +171 -0
- package/src/runtime/pwa.ts +7 -10
- package/src/runtime/regions.ts +33 -0
- package/src/runtime/render.ts +15 -0
- package/src/runtime/report-views.ts +4 -2
- package/src/runtime/report.ts +36 -22
- package/src/runtime/routes.ts +35 -32
- package/src/runtime/signin.ts +80 -0
- package/src/static-version.ts +34 -0
package/src/runtime/grid.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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')}${
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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;
|
package/src/runtime/lists.ts
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
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
|
+
}
|
package/src/runtime/pwa.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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${
|
|
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
|
|
package/src/runtime/regions.ts
CHANGED
|
@@ -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}">
|
package/src/runtime/render.ts
CHANGED
|
@@ -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);
|