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
@@ -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) =>
@@ -36,13 +36,15 @@ import { applyRowSelection, mastersOf } from './master-detail.ts';
36
36
  import { layerInArea, layerTile, parseTile, servedLayer } from './maps.ts';
37
37
  import { parseArea } from './spatial.ts';
38
38
  import { currentLayout, layoutJson, layoutParam, parseLayout, storeLayout } from './grid-layout.ts';
39
- import { layoutFromForm, statePart } from './grid.ts';
39
+ import { gridReturn, layoutFromForm, statePart } from './grid.ts';
40
40
  import { PassThrough } from 'node:stream';
41
41
  import { reportPdf } from './pdf.ts';
42
42
  import { renderDocument } from './documents.ts';
43
43
  import { pwaHead } from './pwa.ts';
44
44
  import { resolveLocale, THEME_COOKIE, translateApp, translatePage, validTimeZone, type Locale } from './locale.ts';
45
45
  import { chrome, dialogClosePage, languagePicker, renderPage } from './render.ts';
46
+ import { LOGIN_ERROR, LOGIN_USER, safeNext, signInForm } from './signin.ts';
47
+ import { passwordResetRoutes } from './password-reset.ts';
46
48
  import { devbarOf, devbarRoutes, sessionDebugLevel } from './devbar.ts';
47
49
  import { availabilityRefusal } from './availability.ts';
48
50
  import { feedbackRoutes } from './feedback.ts';
@@ -85,8 +87,7 @@ export async function appWithLocale(req: FastifyRequest, alias: string, session?
85
87
  return { app, locale };
86
88
  }
87
89
 
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}`;
90
+ export { safeNext };
90
91
 
91
92
  /**
92
93
  * Finish a successful sign-in (password or SSO): replace the session (no
@@ -598,11 +599,15 @@ export async function runtimeRoutes(app: FastifyInstance) {
598
599
  if (messages.length) ctx.session.state.__FLASH = messages.join(' ');
599
600
  await saveState(ctx.session);
600
601
  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);
602
+ // (sprint 54) a grid's Save comes back to the grid's search, page, sort and view
603
+ const back = branchTo || button.target_page ? '' : gridReturn(ctx, button.name);
604
+ return reply.redirect(branchTo ?? (button.target_page ? `${ctx.base}/${button.target_page}` : back ? `${self}${self.includes('?') ? '&' : '?'}${back}` : self), 303);
602
605
  });
603
606
 
604
607
  // ---------------------------------------------------------------- file downloads
605
608
  fileRoutes(app, loadContext, txContext, forbidden);
609
+ // (087) "Forgot password?"
610
+ passwordResetRoutes(app);
606
611
  // (079) the developer toolbar's session state viewer and actions
607
612
  devbarRoutes(app, loadContext);
608
613
  // (084) users' feedback
@@ -1035,34 +1040,13 @@ export async function runtimeRoutes(app: FastifyInstance) {
1035
1040
  // ---------------------------------------------------------------- login / logout
1036
1041
  const loginPage = async (app: App, locale: Locale, session: Session, next: string, error?: string) => {
1037
1042
  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
1043
  const nextQs = next ? `?next=${encodeURIComponent(next)}` : '';
1043
1044
  return documentShell(
1044
1045
  `${t('login.title')} · ${app.name}`,
1045
1046
  html`<main class="login">
1046
1047
  <div class="card login-card">
1047
1048
  <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>`}
1049
+ ${await signInForm(app, locale, session, next, { error })}
1066
1050
  ${languagePicker(app, locale, `/a/${app.alias}/login${nextQs}`)}
1067
1051
  </div>
1068
1052
  </main>`,
@@ -1100,6 +1084,12 @@ export async function runtimeRoutes(app: FastifyInstance) {
1100
1084
  );
1101
1085
  };
1102
1086
 
1087
+ /** (087) The application's own login page: a page that exists and needs no sign-in, else null (the built-in page). */
1088
+ const ownLoginPage = (a: App) => {
1089
+ const p = a.login_page ? a.pages.find((x) => x.page_no === a.login_page) : undefined;
1090
+ return p && !p.requires_auth && a.authentication !== 'header' ? p.page_no : null;
1091
+ };
1092
+
1103
1093
  /** Common start of the sign-in POST handlers: app, session, CSRF, throttling. */
1104
1094
  const loginRequest = async (req: Req, reply: FastifyReply) => {
1105
1095
  const loaded = await appWithLocale(req, req.params.alias);
@@ -1108,20 +1098,30 @@ export async function runtimeRoutes(app: FastifyInstance) {
1108
1098
  const session = await getSession(req, reply, a.id, `/a/${a.alias}`);
1109
1099
  const { username = '', next } = req.body ?? {};
1110
1100
  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;
1101
+ const fail = async (msg: string, code = 401) => {
1102
+ // (087) the application's own login page: back there with the message and user name, shown once
1103
+ const own = ownLoginPage(a);
1104
+ if (own) {
1105
+ session.state[LOGIN_ERROR] = msg;
1106
+ session.state[LOGIN_USER] = username.slice(0, 100);
1107
+ await saveState(session);
1108
+ return reply.redirect(`/a/${a.alias}/${own}${next ? `?next=${encodeURIComponent(safeNext(a, next))}` : ''}`, 303);
1109
+ }
1110
+ return reply.code(code).type('text/html').send(await loginPage(a, locale, session, safeNext(a, next), msg));
1111
+ };
1112
+ if (req.body?.__csrf !== session.csrf_token) return (await fail(locale.t('login.expired_session'), 403), null);
1113
1113
  // automatic time zone: app.js filled in the browser's (signIn() keeps it for the new session)
1114
1114
  if (a.time_zone_auto) {
1115
1115
  const zone = await validTimeZone(req.body?.__tz);
1116
1116
  if (zone) session.state.__TZ = zone;
1117
1117
  }
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;
1118
+ if (a.authentication === 'header') return (await fail(locale.t('login.method_unavailable'), 403), null);
1119
+ if (!a.local_login && a.authentication !== 'database' && a.authentication !== 'custom') return (await fail(locale.t('login.password_disabled'), 403), null);
1120
1120
  // 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;
1121
+ if (username.includes('\0')) return (await fail(locale.t('login.invalid'), 400), null);
1122
1122
  if (await loginThrottled(a.id, username, ip)) {
1123
1123
  logActivity({ appId: a.id, username, event: 'login_locked', ip });
1124
- return fail(locale.t('login.throttled', { minutes: loginWindowMinutes() }), 429), null;
1124
+ return (await fail(locale.t('login.throttled', { minutes: loginWindowMinutes() }), 429), null);
1125
1125
  }
1126
1126
  return { a, locale, session, username: username.slice(0, 100), next, ip, fail };
1127
1127
  };
@@ -1135,6 +1135,9 @@ export async function runtimeRoutes(app: FastifyInstance) {
1135
1135
  if (closed && closed.status === 503) return simplePage(reply, 503, english('app.unavailable_title'), closed.message ?? english(closed.key));
1136
1136
  // header authentication has no sign-in form: the proxy signs the user in on any page
1137
1137
  if (a0.authentication === 'header') return reply.redirect(safeNext(a0, req.query.next));
1138
+ // (087) the application's own login page
1139
+ const own = ownLoginPage(a0);
1140
+ if (own) return reply.redirect(`/a/${a0.alias}/${own}${req.query.next ? `?next=${encodeURIComponent(safeNext(a0, req.query.next))}` : ''}`);
1138
1141
  const session = await getSession(req, reply, a0.id, `/a/${a0.alias}`);
1139
1142
  const { app: a, locale } = (await appWithLocale(req, req.params.alias, session))!;
1140
1143
  const body = await loginPage(a, locale, session, safeNext(a, req.query.next));
@@ -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
+ }
@@ -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()}`;