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
|
@@ -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;
|
package/src/runtime/report.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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) =>
|
package/src/runtime/routes.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
${
|
|
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) =>
|
|
1112
|
-
|
|
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()}`;
|