pgkiln 0.35.0 → 0.37.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 (103) hide show
  1. package/SECURITY.md +11 -9
  2. package/db/migrations/087_login_page.sql +175 -0
  3. package/db/migrations/088_region_templates.sql +18 -0
  4. package/db/migrations/089_page_groups.sql +330 -0
  5. package/db/migrations/090_workspace_isolation.sql +512 -0
  6. package/docs/apex-feature-parity.md +23 -21
  7. package/docs/guide/03-builder.md +85 -8
  8. package/docs/guide/04-pages-and-regions.md +42 -3
  9. package/docs/guide/08-security.md +46 -5
  10. package/docs/guide/11-from-apex.md +4 -1
  11. package/docs/guide/12-development.md +13 -3
  12. package/docs/guide/17-mobile.md +3 -1
  13. package/docs/guide/18-cli.md +2 -0
  14. package/docs/guide/20-ai-agents.md +2 -0
  15. package/examples/hr/hr_40_sample_data.sql +1 -1
  16. package/examples/hr/hr_54_region_templates.sql +67 -0
  17. package/examples/hr/hr_55_page_groups.sql +24 -0
  18. package/package.json +1 -1
  19. package/public/app.css +60 -3
  20. package/public/app.js +89 -3
  21. package/public/builder.css +11 -0
  22. package/public/builder.js +50 -0
  23. package/public/code-editor.css +3 -3
  24. package/public/sw.js +5 -3
  25. package/src/app.ts +20 -0
  26. package/src/appfiles.ts +1 -0
  27. package/src/automations.ts +3 -2
  28. package/src/builder/advisor.ts +25 -5
  29. package/src/builder/ai-builder.ts +27 -18
  30. package/src/builder/blueprints.ts +7 -7
  31. package/src/builder/code-editor.ts +3 -2
  32. package/src/builder/components.ts +15 -2
  33. package/src/builder/dataload.ts +24 -14
  34. package/src/builder/ddl-tools.ts +117 -0
  35. package/src/builder/designer.ts +29 -3
  36. package/src/builder/documents.ts +2 -1
  37. package/src/builder/forms.ts +6 -4
  38. package/src/builder/home.ts +6 -2
  39. package/src/builder/login-page.ts +82 -0
  40. package/src/builder/newapp.ts +21 -3
  41. package/src/builder/quicksql.ts +3 -2
  42. package/src/builder/region-help.ts +4 -2
  43. package/src/builder/region-settings.ts +19 -3
  44. package/src/builder/report-settings.ts +4 -2
  45. package/src/builder/routes.ts +18 -6
  46. package/src/builder/sampledata.ts +22 -18
  47. package/src/builder/scripts.ts +27 -16
  48. package/src/builder/shared.ts +1 -1
  49. package/src/builder/sql.ts +18 -10
  50. package/src/builder/ui.ts +2 -2
  51. package/src/builder/unload.ts +3 -2
  52. package/src/builder/workspaces.ts +175 -15
  53. package/src/cli/main.ts +45 -0
  54. package/src/cli/mcp.ts +42 -0
  55. package/src/cli/replace.ts +4 -0
  56. package/src/db.ts +36 -6
  57. package/src/ddl.ts +510 -0
  58. package/src/debug.ts +3 -2
  59. package/src/i18n/ar.ts +17 -0
  60. package/src/i18n/cs.ts +17 -0
  61. package/src/i18n/da.ts +17 -0
  62. package/src/i18n/de.ts +17 -0
  63. package/src/i18n/el.ts +17 -0
  64. package/src/i18n/es.ts +17 -0
  65. package/src/i18n/fi.ts +17 -0
  66. package/src/i18n/fr.ts +17 -0
  67. package/src/i18n/he.ts +17 -0
  68. package/src/i18n/it.ts +17 -0
  69. package/src/i18n/ja.ts +17 -0
  70. package/src/i18n/ko.ts +17 -0
  71. package/src/i18n/nb.ts +17 -0
  72. package/src/i18n/pl.ts +17 -0
  73. package/src/i18n/pt.ts +17 -0
  74. package/src/i18n/ru.ts +17 -0
  75. package/src/i18n/sv.ts +17 -0
  76. package/src/i18n/tr.ts +17 -0
  77. package/src/i18n/uk.ts +17 -0
  78. package/src/i18n/zh.ts +17 -0
  79. package/src/i18n.ts +34 -0
  80. package/src/icons.ts +2 -1
  81. package/src/layout.ts +3 -2
  82. package/src/metadata.ts +10 -4
  83. package/src/migrate.ts +3 -0
  84. package/src/process-jobs.ts +7 -2
  85. package/src/restsync.ts +3 -2
  86. package/src/runtime/devbar.ts +3 -1
  87. package/src/runtime/display-selector.ts +12 -4
  88. package/src/runtime/grid.ts +170 -26
  89. package/src/runtime/lists.ts +11 -1
  90. package/src/runtime/password-reset.ts +171 -0
  91. package/src/runtime/pwa.ts +7 -10
  92. package/src/runtime/regions.ts +33 -0
  93. package/src/runtime/render.ts +15 -0
  94. package/src/runtime/report-views.ts +4 -2
  95. package/src/runtime/report.ts +36 -22
  96. package/src/runtime/routes.ts +39 -33
  97. package/src/runtime/signin.ts +80 -0
  98. package/src/sampledata.ts +3 -3
  99. package/src/session.ts +5 -2
  100. package/src/static-version.ts +34 -0
  101. package/src/websources.ts +3 -1
  102. package/src/workflow.ts +65 -9
  103. package/src/workspace-db.ts +166 -0
@@ -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);
@@ -11,10 +11,12 @@ import { aggregateFormat, AGGREGATES, cell, columnFormats, filtered, headingOf,
11
11
  // chosen with ?r<id>_v=group|pivot|chart. Each runs one aggregate query
12
12
  // over all filtered rows (search, filters, facets and computed columns
13
13
  // included). Column names come from the result of the report's own query;
14
- // functions from the AGGREGATES whitelist.
14
+ // functions from the AGGREGATES whitelist. An interactive grid has the
15
+ // group by and chart views too, and its own single row view (grid.ts).
15
16
 
16
- export type View = 'report' | 'group' | 'pivot' | 'chart';
17
+ export type View = 'report' | 'group' | 'pivot' | 'chart' | 'row';
17
18
  export const VIEWS: View[] = ['report', 'group', 'pivot', 'chart'];
19
+ export const GRID_VIEWS: View[] = ['report', 'row', 'group', 'chart'];
18
20
 
19
21
  const MAX_GROUPS = 1000;
20
22
  const MAX_PIVOT_VALUES = 30;
@@ -14,7 +14,7 @@ import type { Formatter } from './format.ts';
14
14
  import { XlsxWriter, xlsxWidths, type XlsxCell } from '../xlsx.ts';
15
15
  import { REPORT_CHART_KINDS } from './charts.ts';
16
16
  import { ComputeError, computeNameOk, computeSql, type Computation } from './compute.ts';
17
- import { renderView, VIEWS, type View } from './report-views.ts';
17
+ import { GRID_VIEWS, renderView, VIEWS, type View } from './report-views.ts';
18
18
  import { columnTemplates } from './template-components.ts';
19
19
  import { facetFilterSql, facetFilters, hidesColumns, reportFacetDefs, searchSql, userColumnNames } from './facet-state.ts';
20
20
  import { resolveRestRegion } from './rest-sources.ts';
@@ -259,7 +259,7 @@ export function reportState(ctx: PageContext, r: Region): ReportState {
259
259
  const [name, ...rest] = raw.split('|');
260
260
  return name && computeNameOk(name) && rest.length ? [{ name, expr: rest.join('|'), raw }] : [];
261
261
  }),
262
- view: (VIEWS as string[]).includes(p.get(key(r, 'v')) ?? '') ? (p.get(key(r, 'v')) as View) : 'report',
262
+ view: ((r.type === 'grid' ? GRID_VIEWS : VIEWS) as string[]).includes(p.get(key(r, 'v')) ?? '') ? (p.get(key(r, 'v')) as View) : 'report',
263
263
  groupBy: {
264
264
  columns: p.getAll(key(r, 'g')).filter(Boolean).slice(0, MAX_GROUP_COLUMNS),
265
265
  functions: p.getAll(key(r, 'ga')).slice(0, MAX_GROUP_FUNCTIONS).flatMap((raw) => {
@@ -501,6 +501,38 @@ export function pageInfo(r: Region, st: { page: number; size: number }, rows: un
501
501
  }
502
502
  export type PageInfo = ReturnType<typeof pageInfo>;
503
503
 
504
+ /** Actions → Group by of a report or grid: up to three columns and a function to add (r<id>_gb1… → r<id>_g, r<id>_ga). */
505
+ export function groupBySection(ctx: PageContext, r: Region, st: ReportState, names: string[], form: string) {
506
+ const t = ctx.locale.t;
507
+ const options = (selected: string | undefined) => names.map((n) => html`<option value="${n}"${n === selected ? raw(' selected') : ''}>${headingOf(r, n, ctx.locale.tr)}</option>`);
508
+ return html`<div class="menu-section">
509
+ <strong>${t('report.group_by')}</strong>
510
+ <div class="filter-row">
511
+ ${[1, 2, 3].map((n) => html`<select name="${key(r, `gb${n}`)}" form="${form}" aria-label="${t('report.group_by')} ${n}">
512
+ <option value="">${t('report.none')}</option>${options(st.groupBy.columns[n - 1])}</select>`)}
513
+ <select name="${key(r, 'gbf')}" form="${form}" aria-label="${t('report.function')}"><option value="">${t('report.none')}</option>${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
514
+ <select name="${key(r, 'gbc')}" form="${form}" aria-label="${t('report.column')}">${options(undefined)}</select>
515
+ <button class="btn" form="${form}">${t('report.apply')}</button>
516
+ </div>
517
+ </div>`;
518
+ }
519
+
520
+ /** Actions → Chart of a report or grid: kind, label column, function, value column (r<id>_ck… → r<id>_ch). */
521
+ export function chartSection(ctx: PageContext, r: Region, st: ReportState, names: string[], form: string) {
522
+ const t = ctx.locale.t;
523
+ const options = (selected: string | undefined) => names.map((n) => html`<option value="${n}"${n === selected ? raw(' selected') : ''}>${headingOf(r, n, ctx.locale.tr)}</option>`);
524
+ return html`<div class="menu-section">
525
+ <strong>${t('report.chart')}</strong>
526
+ <div class="filter-row">
527
+ <select name="${key(r, 'ck')}" form="${form}" aria-label="${t('report.chart_type')}">${REPORT_CHART_KINDS.map((k) => html`<option value="${k}"${k === st.chart?.kind ? raw(' selected') : ''}>${t(`chart.${k}`)}</option>`)}</select>
528
+ <select name="${key(r, 'cl')}" form="${form}" aria-label="${t('report.label_column')}">${options(st.chart?.label)}</select>
529
+ <select name="${key(r, 'cf')}" form="${form}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}"${fn === (st.chart?.fn ?? 'count') ? raw(' selected') : ''}>${t(`agg.${fn}`)}</option>`)}</select>
530
+ <select name="${key(r, 'cv')}" form="${form}" aria-label="${t('report.value_column')}">${options(st.chart?.value)}</select>
531
+ <button class="btn" form="${form}">${t('report.apply')}</button>
532
+ </div>
533
+ </div>`;
534
+ }
535
+
504
536
  /** The pager under a report or grid: "X–Y of N" (or "Rows X–Y"), Previous and Next. */
505
537
  export function pagerNav(ctx: PageContext, r: Region, info: PageInfo, linkAttr: Raw | '' = '') {
506
538
  const t = ctx.locale.t;
@@ -1003,16 +1035,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1003
1035
  </div>
1004
1036
  <small class="help">${t('report.compute_help')}</small>
1005
1037
  </div>
1006
- <div class="menu-section">
1007
- <strong>${t('report.group_by')}</strong>
1008
- <div class="filter-row">
1009
- ${[1, 2, 3].map((n) => html`<select name="${key(r, `gb${n}`)}" form="${groupForm}" aria-label="${t('report.group_by')} ${n}">
1010
- <option value="">${t('report.none')}</option>${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.groupBy.columns[n - 1] ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>`)}
1011
- <select name="${key(r, 'gbf')}" form="${groupForm}" aria-label="${t('report.function')}"><option value="">${t('report.none')}</option>${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}">${t(`agg.${fn}`)}</option>`)}</select>
1012
- <select name="${key(r, 'gbc')}" form="${groupForm}" aria-label="${t('report.column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === '' ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1013
- <button class="btn" form="${groupForm}">${t('report.apply')}</button>
1014
- </div>
1015
- </div>
1038
+ ${groupBySection(ctx, r, st, allCols.map(({ f }) => f.name), groupForm)}
1016
1039
  <div class="menu-section">
1017
1040
  <strong>${t('report.pivot')}</strong>
1018
1041
  <div class="filter-row">
@@ -1023,16 +1046,7 @@ export async function renderReport(ctx: PageContext, r: Region, filterItems: Raw
1023
1046
  <button class="btn" form="${pivotForm}">${t('report.apply')}</button>
1024
1047
  </div>
1025
1048
  </div>
1026
- <div class="menu-section">
1027
- <strong>${t('report.chart')}</strong>
1028
- <div class="filter-row">
1029
- <select name="${key(r, 'ck')}" form="${chartForm}" aria-label="${t('report.chart_type')}">${REPORT_CHART_KINDS.map((k) => html`<option value="${k}"${k === st.chart?.kind ? raw(' selected') : ''}>${t(`chart.${k}`)}</option>`)}</select>
1030
- <select name="${key(r, 'cl')}" form="${chartForm}" aria-label="${t('report.label_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.chart?.label ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1031
- <select name="${key(r, 'cf')}" form="${chartForm}" aria-label="${t('report.function')}">${Object.keys(AGGREGATES).map((fn) => html`<option value="${fn}"${fn === (st.chart?.fn ?? 'count') ? raw(' selected') : ''}>${t(`agg.${fn}`)}</option>`)}</select>
1032
- <select name="${key(r, 'cv')}" form="${chartForm}" aria-label="${t('report.value_column')}">${allCols.map(({ f }) => html`<option value="${f.name}"${f.name === st.chart?.value ? raw(' selected') : ''}>${headingOf(r, f.name, ctx.locale.tr)}</option>`)}</select>
1033
- <button class="btn" form="${chartForm}">${t('report.apply')}</button>
1034
- </div>
1035
- </div>
1049
+ ${chartSection(ctx, r, st, allCols.map(({ f }) => f.name), chartForm)}
1036
1050
  ${saved ?? ''}
1037
1051
  <div class="menu-section"><strong>${t('report.rows_per_page')}</strong>
1038
1052
  <div class="seg">${PAGE_SIZES.map((n) =>
@@ -9,6 +9,7 @@ import { headerValue, HeaderAuthError, peerAddress, resolveHeaderAccount, truste
9
9
  import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
10
10
  import { applyBinds } from '../binds.ts';
11
11
  import { appTx, runtime, savepoint } from '../db.ts';
12
+ import { withAppDb } from '../workspace-db.ts';
12
13
  import { html } from '../html.ts';
13
14
  import { documentShell } from '../layout.ts';
14
15
  import { accountRoles, loadApp, loadPage, type App, type Page, type Region } from '../metadata.ts';
@@ -36,13 +37,15 @@ import { applyRowSelection, mastersOf } from './master-detail.ts';
36
37
  import { layerInArea, layerTile, parseTile, servedLayer } from './maps.ts';
37
38
  import { parseArea } from './spatial.ts';
38
39
  import { currentLayout, layoutJson, layoutParam, parseLayout, storeLayout } from './grid-layout.ts';
39
- import { layoutFromForm, statePart } from './grid.ts';
40
+ import { gridReturn, layoutFromForm, statePart } from './grid.ts';
40
41
  import { PassThrough } from 'node:stream';
41
42
  import { reportPdf } from './pdf.ts';
42
43
  import { renderDocument } from './documents.ts';
43
44
  import { pwaHead } from './pwa.ts';
44
45
  import { resolveLocale, THEME_COOKIE, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
45
46
  import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
47
+ import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
48
+ import { passwordResetRoutes } from './password-reset.ts';
46
49
  import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
47
50
  import { availabilityRefusal } from './availability.ts';
48
51
  import { feedbackRoutes } from './feedback.ts';
@@ -85,8 +88,7 @@ export async function appWithLocale(req: FastifyRequest, alias: string, session?
85
88
  return { app, locale };
86
89
  }
87
90
 
88
- export const safeNext = (app: App, next: string | undefined) =>
89
- next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') ? next : `/a/${app.alias}/${app.home_page}`;
91
+ export { safeNext };
90
92
 
91
93
  /**
92
94
  * Finish a successful sign-in (password or SSO): replace the session (no
@@ -598,11 +600,15 @@ export async function runtimeRoutes(app: FastifyInstance) {
598
600
  if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
599
601
  await saveState(ctx.session);
600
602
  if (ctx.dialog) return reply.type('text/html').send(dialogClosePage(ctx));
601
- return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : self), 303);
603
+ // (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
604
+ const back = branchTo || button.target_page ? '' : gridReturn(ctx, button.name);
605
+ return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : back ? `${self}${self.includes('?') ? '&' : '?'}${back}` : self), 303);
602
606
  });
603
607
 
604
608
  // ---------------------------------------------------------------- file downloads
605
609
  fileRoutes(app, loadContext, txContext, forbidden);
610
+ // (087) "Forgot password?"
611
+ passwordResetRoutes(app);
606
612
  // (079) the developer toolbar's session state viewer and actions
607
613
  devbarRoutes(app, loadContext);
608
614
  // (084) users' feedback
@@ -1035,34 +1041,13 @@ export async function runtimeRoutes(app: FastifyInstance) {
1035
1041
  // ---------------------------------------------------------------- login / logout
1036
1042
  const loginPage = async (app: App, locale: Locale, session: Session, next: string, error?: string) => {
1037
1043
  const t = locale.t;
1038
- // database accounts and custom authentication: the user name and password form only
1039
- const dbAuth = app.authentication === 'database' || app.authentication === 'custom';
1040
- if (dbAuth) app = { ...app, local_login: true, remember_me_days: null };
1041
- const providers = dbAuth ? [] : await enabledProviders(app.sso_providers ?? []);
1042
1044
  const nextQs = next ? `?next=${encodeURIComponent(next)}` : '';
1043
1045
  return documentShell(
1044
1046
  `${t('login.title')} · ${app.name}`,
1045
1047
  html`<main class="login">
1046
1048
  <div class="card login-card">
1047
1049
  <h1>${app.name}</h1>
1048
- ${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
1049
- ${((f) => (f ? html`<div class="alert alert-success" role="status">${f}</div>` : ''))(takeFlash(session))}
1050
- ${providers.length
1051
- ? html`<div class="sso-buttons">${providers.map(
1052
- (pr) => html`<a class="btn${app.local_login ? '' : ' btn-hot'}" href="/a/${app.alias}/sso/${pr.name}${nextQs}">${t('login.with', { provider: pr.display_name })}</a>`,
1053
- )}</div>${app.local_login ? html`<div class="or" role="separator"><span>${t('login.or')}</span></div>` : ''}`
1054
- : ''}
1055
- ${app.local_login
1056
- ? html`<form method="post" class="login-form">
1057
- <input type="hidden" name="__csrf" value="${session.csrf_token}">
1058
- <input type="hidden" name="next" value="${next}">
1059
- ${app.time_zone_auto ? html`<input type="hidden" name="__tz" value="">` : ''}
1060
- <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" autocomplete="username" autofocus required maxlength="100"></div>
1061
- <div class="field"><label class="label" for="password">${t('login.password')}</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="200"></div>
1062
- ${app.remember_me_days ? html`<label class="check"><input type="checkbox" name="remember" value="true"> ${t('login.remember', { days: app.remember_me_days })}</label>` : ''}
1063
- <button class="btn btn-hot">${t('login.submit')}</button>
1064
- </form>`
1065
- : providers.length ? '' : html`<p>${t('login.none')}</p>`}
1050
+ ${await signInForm(app, locale, session, next, { error })}
1066
1051
  ${languagePicker(app, locale, `/a/${app.alias}/login${nextQs}`)}
1067
1052
  </div>
1068
1053
  </main>`,
@@ -1100,6 +1085,12 @@ export async function runtimeRoutes(app: FastifyInstance) {
1100
1085
  );
1101
1086
  };
1102
1087
 
1088
+ /** (087) The application's own login page: a page that exists and needs no sign-in, else null (the built-in page). */
1089
+ const ownLoginPage = (a: App) => {
1090
+ const p = a.login_page ? a.pages.find((x) => x.page_no === a.login_page) : undefined;
1091
+ return p && !p.requires_auth && a.authentication !== 'header' ? p.page_no : null;
1092
+ };
1093
+
1103
1094
  /** Common start of the sign-in POST handlers: app, session, CSRF, throttling. */
1104
1095
  const loginRequest = async (req: Req, reply: FastifyReply) => {
1105
1096
  const loaded = await appWithLocale(req, req.params.alias);
@@ -1108,20 +1099,30 @@ export async function runtimeRoutes(app: FastifyInstance) {
1108
1099
  const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
1109
1100
  const { username = '', next } = req.body ?? {};
1110
1101
  const ip = clientIp(req);
1111
- const fail = async (msg: string, code = 401) => reply.code(code).type('text/html').send(await loginPage(a, locale, session, safeNext(a, next), msg));
1112
- if (req.body?.__csrf !== session.csrf_token) return fail(locale.t('login.expired_session'), 403), null;
1102
+ const fail = async (msg: string, code = 401) => {
1103
+ // (087) the application's own login page: back there with the message and user name, shown once
1104
+ const own = ownLoginPage(a);
1105
+ if (own) {
1106
+ session.state[LOGIN_ERROR] = msg;
1107
+ session.state[LOGIN_USER] = username.slice(0, 100);
1108
+ await saveState(session);
1109
+ return reply.redirect(`/a/${a.alias}/${own}${next ? `?next=${encodeURIComponent(safeNext(a, next))}` : ''}`, 303);
1110
+ }
1111
+ return reply.code(code).type('text/html').send(await loginPage(a, locale, session, safeNext(a, next), msg));
1112
+ };
1113
+ if (req.body?.__csrf !== session.csrf_token) return (await fail(locale.t('login.expired_session'), 403), null);
1113
1114
  // automatic time zone: app.js filled in the browser's (signIn() keeps it for the new session)
1114
1115
  if (a.time_zone_auto) {
1115
1116
  const zone = await validTimeZone(req.body?.__tz);
1116
1117
  if (zone) session.state.__TZ = zone;
1117
1118
  }
1118
- if (a.authentication === 'header') return fail(locale.t('login.method_unavailable'), 403), null;
1119
- if (!a.local_login && a.authentication !== 'database' && a.authentication !== 'custom') return fail(locale.t('login.password_disabled'), 403), null;
1119
+ if (a.authentication === 'header') return (await fail(locale.t('login.method_unavailable'), 403), null);
1120
+ if (!a.local_login && a.authentication !== 'database' && a.authentication !== 'custom') return (await fail(locale.t('login.password_disabled'), 403), null);
1120
1121
  // a NUL byte can't be a user name (and PostgreSQL text refuses it)
1121
- if (username.includes('\0')) return fail(locale.t('login.invalid'), 400), null;
1122
+ if (username.includes('\0')) return (await fail(locale.t('login.invalid'), 400), null);
1122
1123
  if (await loginThrottled(a.id, username, ip)) {
1123
1124
  logActivity({ appId: a.id, username, event: 'login_locked', ip });
1124
- return fail(locale.t('login.throttled', { minutes: loginWindowMinutes() }), 429), null;
1125
+ return (await fail(locale.t('login.throttled', { minutes: loginWindowMinutes() }), 429), null);
1125
1126
  }
1126
1127
  return { a, locale, session, username: username.slice(0, 100), next, ip, fail };
1127
1128
  };
@@ -1135,6 +1136,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
1135
1136
  if (closed && closed.status === 503) return simplePage(reply, 503, english('app.unavailable_title'), closed.message ?? english(closed.key));
1136
1137
  // header authentication has no sign-in form: the proxy signs the user in on any page
1137
1138
  if (a0.authentication === 'header') return reply.redirect(safeNext(a0, req.query.next));
1139
+ // (087) the application's own login page
1140
+ const own = ownLoginPage(a0);
1141
+ if (own) return reply.redirect(`/a/${a0.alias}/${own}${req.query.next ? `?next=${encodeURIComponent(safeNext(a0, req.query.next))}` : ''}`);
1138
1142
  const session = await getSession(req, reply, a0.id, `/a/${a0.alias}`);
1139
1143
  const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
1140
1144
  const body = await loginPage(a, locale, session, safeNext(a, req.query.next));
@@ -1271,7 +1275,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
1271
1275
  });
1272
1276
 
1273
1277
  /** The end of a single sign-on (OpenID Connect or SAML): the app's access rules, then the session. */
1274
- const finishSso = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1278
+ const finishSso = (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) =>
1279
+ withAppDb(result.appId, () => finishSsoForApp(req, reply, p, result, ip));
1280
+ const finishSsoForApp = async (req: FastifyRequest, reply: FastifyReply, p: { name: string }, result: SsoResult, ip: string) => {
1275
1281
  const alias = (await runtime.one<{ alias: string }>('select alias from meta.app where id = $1', [result.appId]))?.alias;
1276
1282
  const loaded = alias ? await appWithLocale(req, alias) : undefined;
1277
1283
  if (!loaded || loaded.app.authentication !== 'app_users' || !loaded.app.sso_providers.includes(p.name)) return simplePage(reply, 403, english('login.sso_failed'), english('login.method_unavailable'));
@@ -0,0 +1,80 @@
1
+ import { runtime } from '../db.ts';
2
+ import { html, raw, type Raw } from '../html.ts';
3
+ import type { App } from '../metadata.ts';
4
+ import { takeFlash, type Session } from '../session.ts';
5
+ import { enabledProviders } from '../sso.ts';
6
+ import type { PageContext } from './context.ts';
7
+ import type { Locale } from './locale.ts';
8
+ import { languagePicker } from './render.ts';
9
+
10
+ // The sign-in form: the identity providers' buttons, user name and password,
11
+ // "Keep me signed in" and "Forgot password?". The built-in sign-in page
12
+ // (routes.ts) and the "login" region of an application's own login page
13
+ // (migration 087, meta.app.login_page) both show it; it always posts to
14
+ // /a/<alias>/login, whose checks (CSRF, throttling, every sign-in method)
15
+ // are the same either way.
16
+ //
17
+ // On a login page the form's fields sit inside the page's own form, so they
18
+ // name a separate, detached <form> with the form attribute (formId).
19
+
20
+ /** Where to go after signing in: a path of this application, else its home page. */
21
+ export const safeNext = (app: App, next: string | undefined) =>
22
+ next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') ? next : `/a/${app.alias}/${app.home_page}`;
23
+
24
+ /** "Forgot password?" is offered: turned on, app users with passwords, an enabled mail service. */
25
+ export async function passwordResetAvailable(app: App) {
26
+ if (!app.password_reset) return false;
27
+ return !!(await runtime.one<{ ok: boolean }>('select meta.password_reset_available($1) as ok', [app.id]))?.ok;
28
+ }
29
+
30
+ /** A failed sign-in on an application's own login page: the message and user name, shown once. */
31
+ export const LOGIN_ERROR = '__LOGIN_ERROR';
32
+ export const LOGIN_USER = '__LOGIN_USER';
33
+
34
+ export async function signInForm(
35
+ app: App, locale: Locale, session: Session, next: string,
36
+ o: { error?: string; username?: string; formId?: string } = {},
37
+ ): Promise<Raw> {
38
+ const t = locale.t;
39
+ // database accounts and custom authentication: the user name and password form only
40
+ const dbAuth = app.authentication === 'database' || app.authentication === 'custom';
41
+ const local = dbAuth || app.local_login;
42
+ const remember = dbAuth ? null : app.remember_me_days;
43
+ const providers = dbAuth ? [] : await enabledProviders(app.sso_providers ?? []);
44
+ const nextQs = next ? `?next=${encodeURIComponent(next)}` : '';
45
+ const forgot = !dbAuth && local && (await passwordResetAvailable(app));
46
+ const fa = o.formId ? raw(` form="${o.formId}"`) : '';
47
+ const fields = html`<input type="hidden" name="__csrf" value="${session.csrf_token}"${fa}>
48
+ <input type="hidden" name="next" value="${next}"${fa}>
49
+ ${app.time_zone_auto ? html`<input type="hidden" name="__tz" value=""${fa}>` : ''}
50
+ <div class="field"><label class="label" for="username">${t('login.username')}</label><input id="username" name="username" value="${o.username ?? ''}" autocomplete="username" autofocus required maxlength="100"${fa}></div>
51
+ <div class="field"><label class="label" for="password">${t('login.password')}</label><input id="password" name="password" type="password" autocomplete="current-password" required maxlength="200"${fa}></div>
52
+ ${remember ? html`<label class="check"><input type="checkbox" name="remember" value="true"${fa}> ${t('login.remember', { days: remember })}</label>` : ''}
53
+ <button class="btn btn-hot"${fa}>${t('login.submit')}</button>
54
+ ${forgot ? html`<a class="login-forgot" href="/a/${app.alias}/password/forgot">${t('reset.link')}</a>` : ''}`;
55
+ return html`${o.error ? html`<div class="alert alert-error" role="alert">${o.error}</div>` : ''}
56
+ ${((f) => (f ? html`<div class="alert alert-success" role="status">${f}</div>` : ''))(takeFlash(session))}
57
+ ${providers.length
58
+ ? html`<div class="sso-buttons">${providers.map(
59
+ (pr) => html`<a class="btn${local ? '' : ' btn-hot'}" href="/a/${app.alias}/sso/${pr.name}${nextQs}">${t('login.with', { provider: pr.display_name })}</a>`,
60
+ )}</div>${local ? html`<div class="or" role="separator"><span>${t('login.or')}</span></div>` : ''}`
61
+ : ''}
62
+ ${local
63
+ ? o.formId ? html`<div class="login-form">${fields}</div>` : html`<form method="post" action="/a/${app.alias}/login" class="login-form">${fields}</form>`
64
+ : providers.length ? '' : html`<p>${t('login.none')}</p>`}`;
65
+ }
66
+
67
+ /** The "login" region of an application's own login page. */
68
+ export async function renderLoginRegion(ctx: PageContext): Promise<Raw> {
69
+ const s = ctx.session.state;
70
+ const error = s[LOGIN_ERROR] ?? undefined;
71
+ const username = s[LOGIN_USER] ?? undefined;
72
+ delete s[LOGIN_ERROR];
73
+ delete s[LOGIN_USER];
74
+ const formId = 'pgkiln-login';
75
+ ctx.detached.push(html`<form id="${formId}" method="post" action="/a/${ctx.app.alias}/login"></form>`);
76
+ const next = safeNext(ctx.app, ctx.params.get('next') ?? undefined);
77
+ const here = `${ctx.base}/${ctx.page.page_no}${ctx.params.get('next') ? `?next=${encodeURIComponent(next)}` : ''}`;
78
+ return html`<div class="login-region">${await signInForm(ctx.app, ctx.locale, ctx.session, next, { error, username, formId })}
79
+ ${languagePicker(ctx.app, ctx.locale, here)}</div>`;
80
+ }
package/src/sampledata.ts CHANGED
@@ -169,10 +169,10 @@ type Db = pg.ClientBase | pg.Pool;
169
169
  /** Schemas with at least one ordinary table, the allowed ones only. */
170
170
  export async function schemas(db: Db) {
171
171
  return (
172
- await db.query<{ name: string; tables: number }>(
173
- `select n.nspname as name, count(*)::int as tables from pg_namespace n join pg_class c on c.relnamespace = n.oid
172
+ await db.query<{ name: string; tables: number; usable: boolean }>(
173
+ `select n.nspname as name, count(*)::int as tables, has_schema_privilege(n.oid, 'USAGE') as usable from pg_namespace n join pg_class c on c.relnamespace = n.oid
174
174
  where c.relkind in ('r', 'p') and not c.relispartition and n.nspname !~ '^pg_' and n.nspname not in ('meta', 'information_schema')
175
- group by n.nspname order by n.nspname`,
175
+ group by n.oid, n.nspname order by n.nspname`,
176
176
  )
177
177
  ).rows;
178
178
  }
package/src/session.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { FastifyReply, FastifyRequest } from 'fastify';
2
- import { runtime } from './db.ts';
2
+ import { runtime, sharedRuntime } from './db.ts';
3
+ import { runtimeForApp } from './workspace-db.ts';
3
4
  import { hashToken, loginMaxFailuresPerIp, loginMaxFailuresPerUser, loginWindowMinutes, newToken } from './security.ts';
4
5
  import { instanceSetting } from './instance.ts';
5
6
 
@@ -96,7 +97,9 @@ export async function logActivity(entry: {
96
97
  detail?: string;
97
98
  }) {
98
99
  try {
99
- const r = await runtime.one<{ id: string }>(
100
+ // (090) on the connection of the entry's application (an isolated workspace's own); without one, the shared one
101
+ const db = entry.appId != null ? await runtimeForApp(entry.appId) : sharedRuntime;
102
+ const r = await db.one<{ id: string }>(
100
103
  `insert into meta.activity_log (app_id, page_no, username, event, ip, elapsed_ms, detail)
101
104
  values ($1, $2, $3, $4, $5, $6, $7) returning id`,
102
105
  [entry.appId ?? null, entry.pageNo ?? null, entry.username ?? null, entry.event, entry.ip ?? null, entry.elapsedMs ?? null, entry.detail?.slice(0, 2000) ?? null],
@@ -0,0 +1,34 @@
1
+ import { readFileSync, statSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { root } from './env.ts';
4
+
5
+ // The version of pgkiln's own static files (app.css, app.js, icons.svg, sw.js): a hash of
6
+ // their contents, in their URLs (?v=…) and in each application's service worker. The files'
7
+ // sizes and modification times are looked at again at most once a second, so a changed file
8
+ // gets a new URL and a new service worker even when the server wasn't restarted; a browser
9
+ // (or a service worker cache) never keeps an old copy under the new URL.
10
+
11
+ const FILES = ['app.css', 'app.js', 'icons.svg', 'sw.js'].map((f) => join(root, 'public', f));
12
+ let checked = 0;
13
+ let stamp = '';
14
+ let version = '';
15
+
16
+ export function staticVersion() {
17
+ const now = Date.now();
18
+ if (version && now - checked < 1000) return version;
19
+ checked = now;
20
+ const s = FILES.map((f) => {
21
+ const st = statSync(f);
22
+ return `${st.size}:${st.mtimeMs}`;
23
+ }).join('|');
24
+ if (s !== stamp) {
25
+ let h = 0;
26
+ for (const f of FILES) for (const ch of readFileSync(f, 'utf8')) h = (Math.imul(h, 31) + ch.charCodeAt(0)) | 0;
27
+ stamp = s;
28
+ version = (h >>> 0).toString(36);
29
+ }
30
+ return version;
31
+ }
32
+
33
+ /** /static/<file>?v=<version> for app.css, app.js and icons.svg. */
34
+ export const staticUrl = (file: 'app.css' | 'app.js' | 'icons.svg') => `/static/${file}?v=${staticVersion()}`;
package/src/websources.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
2
  import pg from 'pg';
3
3
  import { owner, runtime } from './db.ts';
4
+ import { runtimeForApp } from './workspace-db.ts';
4
5
  import { decryptSecret, encryptSecret } from './secrets.ts';
5
6
  import { WebError, webRequest, type WebResponse } from './webclient.ts';
6
7
 
@@ -372,7 +373,8 @@ export const withRest = (rowsQuery: string, sql: string | null | undefined) =>
372
373
  // ---------------------------------------------------------------- loading
373
374
 
374
375
  export async function loadSource(appId: number, name: string) {
375
- const s = await runtime.one<RestSource>(
376
+ // the application's own connection (an isolated workspace's), also from a workflow or job (090)
377
+ const s = await (await runtimeForApp(appId)).one<RestSource>(
376
378
  `select id, app_id, name, url, method, credential, headers, params, body, row_selector, columns, cache_seconds, timeout_s, max_rows, key_columns, operations
377
379
  from meta.rest_source where app_id = $1 and name = $2`,
378
380
  [appId, name.toUpperCase()],