pgkiln 0.83.0 → 0.85.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/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +3 -2
- package/docs/guide/03-builder.md +16 -5
- package/docs/guide/04-pages-and-regions.md +42 -0
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +1 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +13 -1
- package/package.json +1 -1
- package/public/app.css +29 -0
- package/public/app.js +78 -0
- package/public/sw.js +5 -1
- package/src/app.ts +11 -0
- package/src/builder/ai-agents.ts +86 -85
- package/src/builder/ai-builder.ts +30 -30
- package/src/builder/ai.ts +93 -92
- package/src/builder/api.ts +47 -47
- package/src/builder/app-groups.ts +33 -32
- package/src/builder/appfromfile.ts +57 -58
- package/src/builder/appsheets.ts +33 -32
- package/src/builder/appwizard.ts +65 -65
- package/src/builder/arrange.ts +23 -22
- package/src/builder/assistant.ts +39 -38
- package/src/builder/automations.ts +32 -31
- package/src/builder/availability.ts +24 -24
- package/src/builder/backups.ts +30 -31
- package/src/builder/blueprints.ts +62 -61
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +10 -6
- package/src/builder/create-page.ts +52 -50
- package/src/builder/cross-app.ts +37 -35
- package/src/builder/dataload.ts +2 -2
- package/src/builder/datasets.ts +107 -106
- package/src/builder/dependencies.ts +20 -19
- package/src/builder/designer.ts +36 -36
- package/src/builder/diagnostics.ts +58 -57
- package/src/builder/features.ts +39 -35
- package/src/builder/feedback.ts +23 -20
- package/src/builder/find-workspace.ts +28 -24
- package/src/builder/form-designer.ts +36 -35
- package/src/builder/gallery.ts +25 -29
- package/src/builder/globalization.ts +50 -49
- package/src/builder/i18n/de.ts +3461 -0
- package/src/builder/i18n/en.ts +3461 -0
- package/src/builder/i18n/es.ts +3461 -0
- package/src/builder/i18n/fr.ts +3461 -0
- package/src/builder/i18n/it.ts +3461 -0
- package/src/builder/i18n/ja.ts +3461 -0
- package/src/builder/i18n/ko.ts +3461 -0
- package/src/builder/i18n/nl.ts +3461 -0
- package/src/builder/i18n/pt.ts +3461 -0
- package/src/builder/i18n/zh-cn.ts +3461 -0
- package/src/builder/i18n/zh-tw.ts +3461 -0
- package/src/builder/i18n.ts +17 -3
- package/src/builder/json-sources.ts +63 -62
- package/src/builder/layouts.ts +12 -11
- package/src/builder/locks.ts +29 -28
- package/src/builder/login-page.ts +26 -25
- package/src/builder/mail.ts +148 -147
- package/src/builder/newapp.ts +15 -12
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/plugins.ts +47 -44
- package/src/builder/process-jobs.ts +11 -10
- package/src/builder/pwa.ts +22 -21
- package/src/builder/remote-databases.ts +55 -56
- package/src/builder/report-settings.ts +1 -1
- package/src/builder/reporter.ts +43 -42
- package/src/builder/rest-catalogs.ts +87 -86
- package/src/builder/rest.ts +9 -8
- package/src/builder/routes.ts +30 -27
- package/src/builder/search.ts +14 -13
- package/src/builder/static-files.ts +31 -32
- package/src/builder/subscriptions.ts +47 -45
- package/src/builder/supporting.ts +23 -18
- package/src/builder/team.ts +179 -179
- package/src/builder/telemetry.ts +59 -62
- package/src/builder/templates.ts +66 -65
- package/src/builder/themeroller.ts +60 -63
- package/src/builder/ui-defaults.ts +21 -20
- package/src/builder/ui.ts +2 -1
- package/src/builder/upgrade.ts +25 -24
- package/src/builder/utilities.ts +48 -47
- package/src/builder/vector-providers.ts +67 -66
- package/src/builder/websources.ts +43 -42
- package/src/builder/wizards.ts +95 -89
- package/src/builder/workflow-migrate.ts +30 -31
- package/src/builder/workflows.ts +40 -39
- package/src/builder/workingcopies.ts +41 -49
- package/src/builder/workspace-backups.ts +21 -20
- package/src/builder/workspace-mail.ts +17 -16
- package/src/builder/workspace-resources.ts +104 -112
- package/src/builder/workspace-transfer.ts +34 -26
- package/src/cli/replace.ts +2 -0
- package/src/i18n/ar.ts +9 -0
- package/src/i18n/cs.ts +9 -0
- package/src/i18n/da.ts +9 -0
- package/src/i18n/de.ts +9 -0
- package/src/i18n/el.ts +9 -0
- package/src/i18n/es.ts +9 -0
- package/src/i18n/fi.ts +9 -0
- package/src/i18n/fr.ts +9 -0
- package/src/i18n/he.ts +9 -0
- package/src/i18n/it.ts +9 -0
- package/src/i18n/ja.ts +9 -0
- package/src/i18n/ko.ts +9 -0
- package/src/i18n/nb.ts +9 -0
- package/src/i18n/pl.ts +9 -0
- package/src/i18n/pt.ts +9 -0
- package/src/i18n/ru.ts +9 -0
- package/src/i18n/sv.ts +9 -0
- package/src/i18n/tr.ts +9 -0
- package/src/i18n/uk.ts +9 -0
- package/src/i18n/zh.ts +9 -0
- package/src/i18n.ts +18 -0
- package/src/metadata.ts +9 -3
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/notifications.ts +237 -0
- package/src/runtime/processes.ts +45 -1
- package/src/runtime/regions.ts +11 -15
- package/src/runtime/render.ts +12 -3
- package/src/runtime/routes.ts +12 -4
- package/src/runtime/signin.ts +2 -1
- package/src/session.ts +13 -4
package/src/runtime/lists.ts
CHANGED
|
@@ -28,6 +28,8 @@ export interface ListNode {
|
|
|
28
28
|
external: boolean;
|
|
29
29
|
icon: string | null;
|
|
30
30
|
badge: string | null;
|
|
31
|
+
/** (154) the entry's id (a query's id column, else its label): finds its badge again for a live refresh */
|
|
32
|
+
key: string;
|
|
31
33
|
description: string | null;
|
|
32
34
|
/** the entry's page is the current page */
|
|
33
35
|
current: boolean;
|
|
@@ -50,6 +52,8 @@ interface Entry {
|
|
|
50
52
|
url: string | null;
|
|
51
53
|
icon: string | null;
|
|
52
54
|
badge: string | null;
|
|
55
|
+
/** (154) a SELECT whose first value is the badge (static entries; wins over badge) */
|
|
56
|
+
badge_query?: string | null;
|
|
53
57
|
description: string | null;
|
|
54
58
|
condition?: string | null;
|
|
55
59
|
authz?: string | null;
|
|
@@ -105,7 +109,7 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
|
|
|
105
109
|
}
|
|
106
110
|
const rows = (
|
|
107
111
|
await runtime.query(
|
|
108
|
-
`select id, parent_id, label, target_page, target_items, target_url, icon, badge, description, condition, authz
|
|
112
|
+
`select id, parent_id, label, target_page, target_items, target_url, icon, badge, badge_query, description, condition, authz
|
|
109
113
|
from meta.list_entry where app_id = $1 and list_name = $2 and meta.build_option_on(app_id, build_option)
|
|
110
114
|
order by seq, id`,
|
|
111
115
|
[ctx.app.id, name.toUpperCase()],
|
|
@@ -113,10 +117,28 @@ async function entriesOf(ctx: PageContext, name: string): Promise<Entry[] | null
|
|
|
113
117
|
).rows;
|
|
114
118
|
return rows.map((r) => ({
|
|
115
119
|
id: r.id, parent_id: r.parent_id, label: ctx.locale.tr(r.label), page: r.target_page, items: r.target_items ?? {}, url: r.target_url,
|
|
116
|
-
icon: r.icon, badge: r.badge, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
|
|
120
|
+
icon: r.icon, badge: r.badge, badge_query: r.badge_query, description: r.description ? ctx.locale.tr(r.description) : null, condition: r.condition, authz: r.authz,
|
|
117
121
|
}));
|
|
118
122
|
}
|
|
119
123
|
|
|
124
|
+
/**
|
|
125
|
+
* (029, 154) A badge from SQL: the first value of the query's first row, run as the application's
|
|
126
|
+
* role with the page's binds; an error is a page error and no badge. Empty values give no badge.
|
|
127
|
+
*/
|
|
128
|
+
export async function queryBadge(ctx: PageContext, query: string, what: string): Promise<string | null> {
|
|
129
|
+
const c = ctx.client;
|
|
130
|
+
if (!c) return null;
|
|
131
|
+
try {
|
|
132
|
+
const sql = stripSemicolon(applyBinds(query, bindValues(ctx)));
|
|
133
|
+
const res = await savepoint(c, () => c.query({ text: sql, rowMode: 'array' }));
|
|
134
|
+
const v = res.rows[0]?.[0];
|
|
135
|
+
return v === null || v === undefined ? null : String(v).trim().slice(0, 100) || null;
|
|
136
|
+
} catch (e) {
|
|
137
|
+
ctx.errors.page.push(await publicError(ctx, e, what));
|
|
138
|
+
return null;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
120
142
|
/** The current page and its parent pages (for "in trail"). */
|
|
121
143
|
function trail(ctx: PageContext) {
|
|
122
144
|
const pages = new Set<number>();
|
|
@@ -156,7 +178,10 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
|
|
|
156
178
|
modal: !!e.page && isModal(ctx, e.page),
|
|
157
179
|
external,
|
|
158
180
|
icon: e.icon,
|
|
159
|
-
badge: e.
|
|
181
|
+
badge: e.badge_query?.trim()
|
|
182
|
+
? await queryBadge(ctx, e.badge_query, `badge of list entry "${e.label}"`)
|
|
183
|
+
: e.badge === null ? null : substitute(e.badge, ctx, (x) => x),
|
|
184
|
+
key: String(e.id ?? e.label),
|
|
160
185
|
description: e.description === null ? null : substitute(e.description, ctx, (x) => x),
|
|
161
186
|
current: isCurrent,
|
|
162
187
|
inTrail: isCurrent || (e.page !== null && current.has(e.page)) || children.some((c) => c.inTrail),
|
|
@@ -179,15 +204,22 @@ export async function listTree(ctx: PageContext, name: string): Promise<ListNode
|
|
|
179
204
|
|
|
180
205
|
const linkAttrs = (n: ListNode, cls?: string) =>
|
|
181
206
|
html`href="${n.href}"${cls ? html` class="${cls}"` : ''}${n.current ? raw(' aria-current="page"') : ''}${n.modal ? raw(' data-dialog') : ''}${n.external ? raw(' rel="noopener noreferrer"') : ''}`;
|
|
182
|
-
|
|
207
|
+
/**
|
|
208
|
+
* An entry's badge. live: the prefix of the badge's data-badge key when the application refreshes
|
|
209
|
+
* badges (154); an empty badge is then a hidden placeholder the refresh can fill.
|
|
210
|
+
*/
|
|
211
|
+
const badge = (n: ListNode, live?: string) =>
|
|
212
|
+
n.badge !== null
|
|
213
|
+
? html` <span class="badge"${live ? html` data-badge="${live}${n.key}"` : ''}>${n.badge}</span>`
|
|
214
|
+
: live ? html` <span class="badge" data-badge="${live}${n.key}" hidden></span>` : '';
|
|
183
215
|
|
|
184
216
|
/** Entries as the navigation menu (the same markup as navigation entries). */
|
|
185
|
-
export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
|
|
217
|
+
export function navMarkup(nodes: ListNode[], topNav: boolean, live?: string): Raw[] {
|
|
186
218
|
return nodes.map((n) => {
|
|
187
|
-
const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n)}`;
|
|
219
|
+
const label = html`${icon(n.icon ?? 'chevron')}<span>${n.label}</span>${badge(n, live)}`;
|
|
188
220
|
if (n.children.length)
|
|
189
221
|
return html`<li><details${n.inTrail && !topNav ? raw(' open') : ''}><summary class="${n.inTrail ? 'in-trail' : null}">${label}</summary><ul>${
|
|
190
|
-
n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav)}</ul></details></li>`;
|
|
222
|
+
n.href ? html`<li><a ${linkAttrs(n)}>${label}</a></li>` : ''}${navMarkup(n.children, topNav, live)}</ul></details></li>`;
|
|
191
223
|
return html`<li><a ${linkAttrs(n, !n.current && n.inTrail ? 'in-trail' : undefined)}>${label}</a></li>`;
|
|
192
224
|
});
|
|
193
225
|
}
|
|
@@ -195,13 +227,13 @@ export function navMarkup(nodes: ListNode[], topNav: boolean): Raw[] {
|
|
|
195
227
|
const hasIcon = (n: ListNode) => isIcon(n.icon);
|
|
196
228
|
|
|
197
229
|
/** Entries as the navigation bar in the header: links, with a menu for entries with children (labels of entries with an icon hide on small screens). */
|
|
198
|
-
export function navbarMarkup(nodes: ListNode[], label: string): Raw {
|
|
230
|
+
export function navbarMarkup(nodes: ListNode[], label: string, live?: string): Raw {
|
|
199
231
|
if (!nodes.length) return raw('');
|
|
200
232
|
return html`<nav class="t-navbar" aria-label="${label}"><ul>${nodes.map((n) =>
|
|
201
233
|
n.children.length
|
|
202
|
-
? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
|
|
203
|
-
[...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c)}</a></li>`)}</ul></div></details></li>`
|
|
204
|
-
: html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n)}</a></li>`)}</ul></nav>`;
|
|
234
|
+
? html`<li><details class="menu"><summary${hasIcon(n) ? html` class="nb-icon" aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</summary><div class="menu-panel align-right"><ul class="t-navbar-menu">${
|
|
235
|
+
[...(n.href ? [n] : []), ...n.children].map((c) => html`<li><a ${linkAttrs(c)}>${icon(c.icon)}<span>${c.label}</span>${badge(c, live)}</a></li>`)}</ul></div></details></li>`
|
|
236
|
+
: html`<li><a ${linkAttrs(n, hasIcon(n) ? 'nb-icon' : undefined)}${hasIcon(n) ? html` aria-label="${n.label}"` : ''}>${icon(n.icon)}<span>${n.label}</span>${badge(n, live)}</a></li>`)}</ul></nav>`;
|
|
205
237
|
}
|
|
206
238
|
|
|
207
239
|
function linksMarkup(nodes: ListNode[]): Raw {
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { appTx, runtime } from '../db.ts';
|
|
3
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
4
|
+
import { icon } from '../icons.ts';
|
|
5
|
+
import { checkPageAccess, computeVisibility, Forbidden } from './authz.ts';
|
|
6
|
+
import { publicError, type PageContext } from './context.ts';
|
|
7
|
+
import { listTree, queryBadge, type ListNode } from './lists.ts';
|
|
8
|
+
import { buttonBadgeKey } from './regions.ts';
|
|
9
|
+
import { chrome, clientTexts } from './render.ts';
|
|
10
|
+
import { safeNext } from './signin.ts';
|
|
11
|
+
import { saveState, takeFlash } from '../session.ts';
|
|
12
|
+
import { loadContext, txContext, type Req } from './routes.ts';
|
|
13
|
+
|
|
14
|
+
// (sprint 165, migration 154) In-app notifications and live badges (APEX: a notification bell in the
|
|
15
|
+
// header, navigation menu badges, badges refreshed with dynamic actions and JavaScript).
|
|
16
|
+
//
|
|
17
|
+
// Application code adds notifications with meta.notify() or the send_notification process. With
|
|
18
|
+
// meta.app.notifications on, signed-in users get a bell in the header: the number of unread
|
|
19
|
+
// notifications and the newest ones. Opening one (a POST with the session's CSRF token) marks it read
|
|
20
|
+
// and goes to its page (a link signed for the recipient when it was made). /a/<alias>/notifications
|
|
21
|
+
// lists them all. Only the user's own notifications of this application are ever read or changed:
|
|
22
|
+
// every query has the application's id and the session's user name.
|
|
23
|
+
//
|
|
24
|
+
// With meta.app.live_badges_s set, the page asks GET /a/<alias>/<page>/live every so many seconds for
|
|
25
|
+
// the badges of its navigation menu and bar (list entries), of its buttons with a badge query, and for
|
|
26
|
+
// the bell. These requests don't keep an idle session alive (loadContext touch: false).
|
|
27
|
+
|
|
28
|
+
/** Newest notifications in the bell's menu. */
|
|
29
|
+
const BELL_ROWS = 8;
|
|
30
|
+
/** Notifications on the notifications page. */
|
|
31
|
+
const PAGE_ROWS = 100;
|
|
32
|
+
|
|
33
|
+
interface Row {
|
|
34
|
+
id: string;
|
|
35
|
+
title: string;
|
|
36
|
+
body: string | null;
|
|
37
|
+
url: string | null;
|
|
38
|
+
icon: string | null;
|
|
39
|
+
created_at: Date | string;
|
|
40
|
+
read_at: Date | string | null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const signedIn = (ctx: PageContext) => ctx.app.authentication !== 'none' && ctx.user !== 'nobody';
|
|
44
|
+
/** The bell is shown: notifications on, a signed-in user, a page with the header. */
|
|
45
|
+
export const hasBell = (ctx: PageContext) => !!ctx.app.notifications && signedIn(ctx);
|
|
46
|
+
|
|
47
|
+
async function rows(ctx: PageContext, limit: number): Promise<Row[]> {
|
|
48
|
+
return (
|
|
49
|
+
await runtime.query<Row>(
|
|
50
|
+
`select id::text, title, body, url, icon, created_at, read_at from meta.notification
|
|
51
|
+
where app_id = $1 and lower(username) = lower($2) order by id desc limit $3`,
|
|
52
|
+
[ctx.app.id, ctx.user, limit],
|
|
53
|
+
)
|
|
54
|
+
).rows;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function unreadCount(ctx: PageContext): Promise<number> {
|
|
58
|
+
const r = await runtime.one<{ n: number }>(
|
|
59
|
+
'select count(*)::int as n from meta.notification where app_id = $1 and lower(username) = lower($2) and read_at is null',
|
|
60
|
+
[ctx.app.id, ctx.user],
|
|
61
|
+
);
|
|
62
|
+
return r?.n ?? 0;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const shown = (n: number) => (n > 99 ? '99+' : String(n));
|
|
66
|
+
|
|
67
|
+
/** A notification's time in the user's language and time zone (the pool gives timestamps as text). */
|
|
68
|
+
function when(ctx: PageContext, value: Date | string) {
|
|
69
|
+
const d = value instanceof Date ? value : new Date(value);
|
|
70
|
+
if (Number.isNaN(d.getTime())) return String(value);
|
|
71
|
+
try {
|
|
72
|
+
return new Intl.DateTimeFormat(ctx.locale.lang, { dateStyle: 'medium', timeStyle: 'short', timeZone: ctx.locale.timeZone ?? undefined }).format(d);
|
|
73
|
+
} catch {
|
|
74
|
+
return d.toISOString().slice(0, 16).replace('T', ' ');
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** A notification as a button that opens it (a form: it marks the notification read). */
|
|
79
|
+
function entry(ctx: PageContext, r: Row, cls: string): Raw {
|
|
80
|
+
const t = ctx.locale.t;
|
|
81
|
+
return html`<li class="${cls}${r.read_at ? '' : ' is-unread'}"><form method="post" action="${ctx.base}/notifications/${r.id}/open">
|
|
82
|
+
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}">
|
|
83
|
+
<button class="t-notification">${icon(r.icon ?? 'bell')}<span class="t-notification-text">
|
|
84
|
+
<span class="t-notification-title">${r.read_at ? '' : html`<span class="sr-only">${t('notify.unread_one')}: </span>`}${r.title}</span>
|
|
85
|
+
${r.body ? html`<span class="t-notification-body">${r.body}</span>` : ''}
|
|
86
|
+
<span class="t-notification-time">${when(ctx, r.created_at)}</span></span></button>
|
|
87
|
+
</form></li>`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** "Mark all read", back to next. */
|
|
91
|
+
function markAllForm(ctx: PageContext, next: string): Raw {
|
|
92
|
+
return html`<form method="post" action="${ctx.base}/notifications/read" class="t-bell-read">
|
|
93
|
+
<input type="hidden" name="__csrf" value="${ctx.session.csrf_token}"><input type="hidden" name="next" value="${next}">
|
|
94
|
+
<button class="link-button">${icon('check')} ${ctx.locale.t('notify.mark_all_read')}</button></form>`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The bell's menu: the newest notifications, Mark all read, All notifications. */
|
|
98
|
+
async function bellPanel(ctx: PageContext, unread: number): Promise<Raw> {
|
|
99
|
+
const t = ctx.locale.t;
|
|
100
|
+
const list = await rows(ctx, BELL_ROWS);
|
|
101
|
+
return html`<div class="menu-section t-bell-head"><strong>${t('notify.title')}</strong>${unread ? markAllForm(ctx, `${ctx.base}/${ctx.page.page_no}`) : ''}</div>
|
|
102
|
+
${list.length ? html`<ul class="t-bell-list">${list.map((r) => entry(ctx, r, 't-bell-entry'))}</ul>` : html`<p class="muted t-bell-none">${t('notify.none')}</p>`}
|
|
103
|
+
<div class="menu-section"><a href="${ctx.base}/notifications">${icon('arrow-right')} ${t('notify.all')}</a></div>`;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const bellLabel = (ctx: PageContext, unread: number) =>
|
|
107
|
+
unread ? ctx.locale.t(unread === 1 ? 'notify.bell_one' : 'notify.bell_n', { n: unread }) : ctx.locale.t('notify.title');
|
|
108
|
+
|
|
109
|
+
/** The bell in the header (empty when the application has no notifications or nobody is signed in). */
|
|
110
|
+
export async function bellMarkup(ctx: PageContext): Promise<Raw | ''> {
|
|
111
|
+
if (!hasBell(ctx)) return '';
|
|
112
|
+
const unread = await unreadCount(ctx);
|
|
113
|
+
return html`<details class="menu t-bell" data-bell>
|
|
114
|
+
<summary class="icon-button" title="${bellLabel(ctx, unread)}" aria-label="${bellLabel(ctx, unread)}">${icon('bell')}<span class="t-bell-count" data-badge="bell"${unread ? '' : raw(' hidden')}>${unread ? shown(unread) : ''}</span></summary>
|
|
115
|
+
<div class="menu-panel align-right t-bell-panel" data-bell-panel>${await bellPanel(ctx, unread)}</div>
|
|
116
|
+
</details>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Every node of a list tree, depth first. */
|
|
120
|
+
const allNodes = (nodes: ListNode[]): ListNode[] => nodes.flatMap((n) => [n, ...allNodes(n.children)]);
|
|
121
|
+
|
|
122
|
+
export interface LiveState {
|
|
123
|
+
/** data-badge key → its text (null: no badge) */
|
|
124
|
+
badges: Record<string, string | null>;
|
|
125
|
+
bell: { unread: number; label: string; panel: string } | null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The page's badges now (navigation menu and bar, buttons with a badge query) and the bell. */
|
|
129
|
+
export async function liveState(ctx: PageContext): Promise<LiveState> {
|
|
130
|
+
const badges: Record<string, string | null> = {};
|
|
131
|
+
for (const [list, prefix] of [[ctx.app.nav_list, 'n:'], [ctx.app.navbar_list, 'b:']] as const) {
|
|
132
|
+
if (!list) continue;
|
|
133
|
+
for (const n of allNodes((await listTree(ctx, list)) ?? [])) badges[`${prefix}${n.key}`] = n.badge;
|
|
134
|
+
}
|
|
135
|
+
for (const b of ctx.vis!.buttons.values())
|
|
136
|
+
if (b.badge_query?.trim() && b.id > 0) badges[buttonBadgeKey(b)] = await queryBadge(ctx, b.badge_query, `badge of button ${b.name}`);
|
|
137
|
+
return { badges, bell: null };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export async function notificationRoutes(app: FastifyInstance) {
|
|
141
|
+
/** A signed-in user of an application with notifications on (else a 404). */
|
|
142
|
+
const context = async (req: Req, reply: FastifyReply) => {
|
|
143
|
+
const ctx = await loadContext(req, reply, { pageNo: 'home' });
|
|
144
|
+
if (!ctx) return null;
|
|
145
|
+
if (!hasBell(ctx)) {
|
|
146
|
+
reply.code(404).type('text/plain').send('Not found');
|
|
147
|
+
return null;
|
|
148
|
+
}
|
|
149
|
+
return ctx;
|
|
150
|
+
};
|
|
151
|
+
|
|
152
|
+
app.get('/a/:alias/notifications', async (req: Req, reply) => {
|
|
153
|
+
const ctx = await context(req, reply);
|
|
154
|
+
if (!ctx) return;
|
|
155
|
+
const t = ctx.locale.t;
|
|
156
|
+
const list = await rows(ctx, PAGE_ROWS);
|
|
157
|
+
const unread = list.some((r) => !r.read_at);
|
|
158
|
+
const flash = takeFlash(ctx.session);
|
|
159
|
+
const main = html`<div class="t-titlebar"><h1>${t('notify.title')}</h1></div>
|
|
160
|
+
<div class="t-content notifications-page">
|
|
161
|
+
<div class="messages" aria-live="polite">${flash ? html`<div class="alert alert-success" role="status">${flash}</div>` : ''}</div>
|
|
162
|
+
<section class="region region-standard"><div class="region-body">
|
|
163
|
+
${unread ? html`<div class="buttons u-mt0">${markAllForm(ctx, `${ctx.base}/notifications`)}</div>` : ''}
|
|
164
|
+
${list.length ? html`<ul class="t-notification-list">${list.map((r) => entry(ctx, r, 't-notification-entry'))}</ul>` : html`<p class="muted">${t('notify.none')}</p>`}
|
|
165
|
+
</div></section>
|
|
166
|
+
</div>
|
|
167
|
+
<script type="application/json" id="pgkiln-meta">${raw(JSON.stringify({ csrf: ctx.session.csrf_token, das: [], texts: clientTexts(ctx) }).replace(/</g, '\\u003c'))}</script>`;
|
|
168
|
+
ctx.vis = { regions: new Set(), items: new Set(), editable: new Set(), buttons: new Map(), dynamicActions: new Set() };
|
|
169
|
+
const body = await appTx(txContext(ctx), async (c) => {
|
|
170
|
+
ctx.client = c;
|
|
171
|
+
return chrome(ctx, main, t('notify.title'));
|
|
172
|
+
});
|
|
173
|
+
await saveState(ctx.session);
|
|
174
|
+
return reply.header('cache-control', 'private, no-store').type('text/html').send(body);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// open a notification: mark it read, go to its page (or the list when it has none)
|
|
178
|
+
app.post('/a/:alias/notifications/:id/open', async (req: Req, reply) => {
|
|
179
|
+
const ctx = await context(req, reply);
|
|
180
|
+
if (!ctx) return;
|
|
181
|
+
if (req.body?.__csrf !== ctx.session.csrf_token) return reply.redirect(`${ctx.base}/notifications`, 303);
|
|
182
|
+
const id = /^[1-9][0-9]{0,17}$/.test(String(req.params.id)) ? String(req.params.id) : null;
|
|
183
|
+
const row = id
|
|
184
|
+
? await runtime.one<{ url: string | null }>(
|
|
185
|
+
`update meta.notification set read_at = coalesce(read_at, now())
|
|
186
|
+
where id = $1 and app_id = $2 and lower(username) = lower($3) returning url`,
|
|
187
|
+
[id, ctx.app.id, ctx.user],
|
|
188
|
+
)
|
|
189
|
+
: null;
|
|
190
|
+
if (!row) {
|
|
191
|
+
ctx.session.state.__FLASH = ctx.locale.t('notify.gone');
|
|
192
|
+
await saveState(ctx.session);
|
|
193
|
+
return reply.redirect(`${ctx.base}/notifications`, 303);
|
|
194
|
+
}
|
|
195
|
+
// the link was made under the application's alias then; it goes to the same page now
|
|
196
|
+
const rest = row.url?.replace(/^\/a\/[^/]+\//, '');
|
|
197
|
+
return reply.redirect(rest ? `${ctx.base}/${rest}` : `${ctx.base}/notifications`, 303);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
app.post('/a/:alias/notifications/read', async (req: Req, reply) => {
|
|
201
|
+
const ctx = await context(req, reply);
|
|
202
|
+
if (!ctx) return;
|
|
203
|
+
const next = safeNext(ctx.app, typeof req.body?.next === 'string' ? req.body.next : `${ctx.base}/notifications`);
|
|
204
|
+
if (req.body?.__csrf !== ctx.session.csrf_token) return reply.redirect(next, 303);
|
|
205
|
+
await runtime.query(
|
|
206
|
+
'update meta.notification set read_at = now() where app_id = $1 and lower(username) = lower($2) and read_at is null',
|
|
207
|
+
[ctx.app.id, ctx.user],
|
|
208
|
+
);
|
|
209
|
+
ctx.session.state.__FLASH = ctx.locale.t('notify.marked');
|
|
210
|
+
await saveState(ctx.session);
|
|
211
|
+
return reply.redirect(next, 303);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
// live badges: what the page shows now; never extends the session
|
|
215
|
+
app.get('/a/:alias/:page/live', async (req: Req, reply) => {
|
|
216
|
+
const ctx = await loadContext(req, reply, { json: true, touch: false });
|
|
217
|
+
if (!ctx) return;
|
|
218
|
+
reply.header('cache-control', 'private, no-store');
|
|
219
|
+
if (!ctx.app.live_badges_s) return reply.code(404).send({ error: 'not found' });
|
|
220
|
+
try {
|
|
221
|
+
const state = await appTx(txContext(ctx), async (c) => {
|
|
222
|
+
ctx.client = c;
|
|
223
|
+
await checkPageAccess(ctx);
|
|
224
|
+
ctx.vis = await computeVisibility(ctx);
|
|
225
|
+
return liveState(ctx);
|
|
226
|
+
});
|
|
227
|
+
if (hasBell(ctx)) {
|
|
228
|
+
const unread = await unreadCount(ctx);
|
|
229
|
+
state.bell = { unread, label: bellLabel(ctx, unread), panel: (await bellPanel(ctx, unread)).toString() };
|
|
230
|
+
}
|
|
231
|
+
return reply.send(state);
|
|
232
|
+
} catch (e) {
|
|
233
|
+
if (e instanceof Forbidden) return reply.code(403).send({ error: e.message });
|
|
234
|
+
return reply.code(400).send({ error: await publicError(ctx, e, 'live badges') });
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
}
|
package/src/runtime/processes.ts
CHANGED
|
@@ -176,7 +176,7 @@ export function chainProblems(conf: unknown): string[] {
|
|
|
176
176
|
}
|
|
177
177
|
|
|
178
178
|
/** Process types a background chain can run (nothing that needs the request: forms, grids, uploads, files). */
|
|
179
|
-
export const BACKGROUND_TYPES = new Set<Process['type']>(['sql', 'invoke_api', 'workflow', 'chain', 'send_push', 'send_email']);
|
|
179
|
+
export const BACKGROUND_TYPES = new Set<Process['type']>(['sql', 'invoke_api', 'workflow', 'chain', 'send_push', 'send_email', 'send_notification']);
|
|
180
180
|
|
|
181
181
|
// ---------------------------------------------------------------- workflows
|
|
182
182
|
|
|
@@ -248,6 +248,7 @@ export function processProblems(type: string, conf: unknown): string[] {
|
|
|
248
248
|
if (type === 'chain') return chainProblems(conf);
|
|
249
249
|
if (type === 'workflow') return workflowProblems(conf);
|
|
250
250
|
if (type === 'send_push') return sendPushProblems(conf);
|
|
251
|
+
if (type === 'send_notification') return sendNotificationProblems(conf);
|
|
251
252
|
if (type === 'send_email') return sendEmailProblems(conf);
|
|
252
253
|
if (DECLARATIVE_PROCESS_TYPES.has(type)) return declarativeProblems(type, conf);
|
|
253
254
|
return [];
|
|
@@ -298,6 +299,49 @@ export async function sendPushProcess(ctx: PageContext, p: Process): Promise<str
|
|
|
298
299
|
return p.success_message ?? null;
|
|
299
300
|
}
|
|
300
301
|
|
|
302
|
+
// ---------------------------------------------------------------- send_notification (154)
|
|
303
|
+
|
|
304
|
+
interface SendNotificationConfig {
|
|
305
|
+
to: string;
|
|
306
|
+
title: string;
|
|
307
|
+
body?: string;
|
|
308
|
+
page?: number;
|
|
309
|
+
items?: Record<string, string>;
|
|
310
|
+
icon?: string;
|
|
311
|
+
push?: boolean;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
export function sendNotificationProblems(conf: unknown): string[] {
|
|
315
|
+
const c = (conf ?? {}) as Record<string, any>;
|
|
316
|
+
const out: string[] = [];
|
|
317
|
+
if (typeof c.to !== 'string' || !c.to.trim()) out.push('"to" is the user name, e.g. "&P5_OWNER." or "&APP_USER.".');
|
|
318
|
+
if (typeof c.title !== 'string' || !c.title.trim()) out.push('"title" is the notification\'s title.');
|
|
319
|
+
if (c.body !== undefined && typeof c.body !== 'string') out.push('"body" is text.');
|
|
320
|
+
if (c.page !== undefined && !(Number.isInteger(c.page) && c.page > 0)) out.push('"page" is a page number of this application.');
|
|
321
|
+
if (c.items !== undefined && (typeof c.items !== 'object' || Array.isArray(c.items) || c.items === null || Object.entries(c.items).some(([k, v]) => !ITEM.test(k.toUpperCase()) || typeof v !== 'string')))
|
|
322
|
+
out.push('"items" maps item names of the page to values, e.g. {"P5_ID": "&P5_ID."}.');
|
|
323
|
+
if (c.items !== undefined && c.page === undefined) out.push('"items" needs a "page".');
|
|
324
|
+
if (c.icon !== undefined && (typeof c.icon !== 'string' || !/^[a-z0-9-]{1,40}$/.test(c.icon))) out.push('"icon" is an icon name, e.g. "bell" or "check".');
|
|
325
|
+
if (c.push !== undefined && typeof c.push !== 'boolean') out.push('"push" is true or false (also to the user\'s devices when push notifications are on).');
|
|
326
|
+
return out;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* send_notification: adds an in-app notification (the bell) for a user with meta.notify, in the page's
|
|
331
|
+
* transaction, so it exists only when the submit commits. Values take &ITEM. substitutions.
|
|
332
|
+
*/
|
|
333
|
+
export async function sendNotificationProcess(ctx: PageContext, p: Process): Promise<string | null> {
|
|
334
|
+
const conf = (p.config ?? {}) as unknown as SendNotificationConfig;
|
|
335
|
+
const problems = sendNotificationProblems(conf);
|
|
336
|
+
if (problems.length) throw new Error(`Process "${p.name}": ${problems.join(' ')}`);
|
|
337
|
+
const value = (s: string | undefined) => (s === undefined ? null : substitute(s, ctx, (x) => x));
|
|
338
|
+
const items = Object.fromEntries(Object.entries(conf.items ?? {}).map(([k, v]) => [k.toUpperCase(), value(v) ?? '']));
|
|
339
|
+
await ctx.client!.query('select meta.notify($1, $2, $3, $4, $5::jsonb, $6, $7)', [
|
|
340
|
+
(value(conf.to) ?? '').trim(), value(conf.title)?.slice(0, 200), value(conf.body)?.slice(0, 1000) || null, conf.page ?? null, JSON.stringify(items), conf.icon ?? null, conf.push === true,
|
|
341
|
+
]);
|
|
342
|
+
return p.success_message ?? null;
|
|
343
|
+
}
|
|
344
|
+
|
|
301
345
|
// ---------------------------------------------------------------- send_email (081)
|
|
302
346
|
|
|
303
347
|
interface SendEmailConfig {
|
package/src/runtime/regions.ts
CHANGED
|
@@ -16,7 +16,7 @@ import { CHART_KINDS, renderChartBody, wallClockIn, type GaugeConfig } from './c
|
|
|
16
16
|
import { databaseTimeZone } from './locale.ts';
|
|
17
17
|
import { renderFacets } from './facets.ts';
|
|
18
18
|
import { renderSmartFilters } from './smart-filters.ts';
|
|
19
|
-
import { renderListRegion } from './lists.ts';
|
|
19
|
+
import { queryBadge, renderListRegion } from './lists.ts';
|
|
20
20
|
import { renderDisplaySelector } from './display-selector.ts';
|
|
21
21
|
import { renderGrid } from './grid.ts';
|
|
22
22
|
import { masterItemOf, mastersOf } from './master-detail.ts';
|
|
@@ -44,22 +44,18 @@ import { renderBreadcrumbRegion, renderHelpTextRegion } from './page-regions.ts'
|
|
|
44
44
|
|
|
45
45
|
/** A button's badge: the badge query's first value, or the badge text with &ITEM. substitutions. */
|
|
46
46
|
async function badgeOf(ctx: PageContext, b: Button): Promise<Raw | ''> {
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
value = v === null || v === undefined ? null : String(v);
|
|
55
|
-
} catch (e) {
|
|
56
|
-
ctx.errors.page.push(await publicError(ctx, e, `badge of button ${b.name}`));
|
|
57
|
-
}
|
|
58
|
-
} else if (b.badge) value = substitute(b.badge, ctx, (x) => x);
|
|
59
|
-
value = value?.trim().slice(0, 100) || null;
|
|
60
|
-
return value ? html` <span class="btn-badge">${value}</span>` : '';
|
|
47
|
+
const value = b.badge_query?.trim()
|
|
48
|
+
? await queryBadge(ctx, b.badge_query, `badge of button ${b.name}`)
|
|
49
|
+
: b.badge ? substitute(b.badge, ctx, (x) => x).trim().slice(0, 100) || null : null;
|
|
50
|
+
// (154) with live badges, a badge from a query keeps a place the refresh can fill
|
|
51
|
+
const live = ctx.app.live_badges_s && b.badge_query?.trim() ? html` data-badge="${buttonBadgeKey(b)}"` : '';
|
|
52
|
+
if (!value) return live ? html` <span class="btn-badge"${live} hidden></span>` : '';
|
|
53
|
+
return html` <span class="btn-badge"${live}>${value}</span>`;
|
|
61
54
|
}
|
|
62
55
|
|
|
56
|
+
/** (154) The data-badge key of a button's badge (live badges). */
|
|
57
|
+
export const buttonBadgeKey = (b: { id: number }) => `btn:${b.id}`;
|
|
58
|
+
|
|
63
59
|
/**
|
|
64
60
|
* A menu button: a <details> dropdown (works without JavaScript) with links
|
|
65
61
|
* to pages (signed item values, only pages the user may open), submit
|
package/src/runtime/render.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { runtime, savepoint } from '../db.ts';
|
|
|
19
19
|
import { renderItems } from './items.ts';
|
|
20
20
|
import { buttonsFor, renderRegion } from './regions.ts';
|
|
21
21
|
import { listTree, navbarMarkup, navMarkup } from './lists.ts';
|
|
22
|
+
import { bellMarkup } from './notifications.ts';
|
|
22
23
|
import { aiInputs, aiOutputs, aiProcessOf } from './ai.ts';
|
|
23
24
|
import { frameOrigins } from './url-region.ts';
|
|
24
25
|
import { breadcrumbLinks, hasBreadcrumbRegion } from './page-regions.ts';
|
|
@@ -108,7 +109,8 @@ async function navTree(ctx: PageContext, topNav = false) {
|
|
|
108
109
|
// a list as the navigation menu (falls back to the navigation entries when the list is missing)
|
|
109
110
|
if (ctx.app.nav_list) {
|
|
110
111
|
const nodes = await listTree(ctx, ctx.app.nav_list);
|
|
111
|
-
|
|
112
|
+
// (154) with live badges, every entry's badge has a key the refresh finds it by
|
|
113
|
+
if (nodes) return navMarkup(nodes, topNav, ctx.app.live_badges_s ? 'n:' : undefined);
|
|
112
114
|
}
|
|
113
115
|
const current = new Set<number>();
|
|
114
116
|
for (let p: number | null | undefined = ctx.page.page_no, guard = 0; p && guard < 10; guard++) {
|
|
@@ -267,7 +269,9 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
267
269
|
// (084) the Feedback entry, after the application's own navigation bar list
|
|
268
270
|
const feedback = feedbackNode(ctx);
|
|
269
271
|
const navbarNodes = listNodes || feedback ? [...(listNodes ?? []), ...(feedback ? [feedback] : [])] : null;
|
|
270
|
-
const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar')) : '';
|
|
272
|
+
const navbar = navbarNodes ? navbarMarkup(navbarNodes, t('list.navbar'), ctx.app.live_badges_s ? 'b:' : undefined) : '';
|
|
273
|
+
// (154) the notification bell
|
|
274
|
+
const bell = await bellMarkup(ctx);
|
|
271
275
|
// (sprint 72) the account's display name in the user menu
|
|
272
276
|
const shown = signedIn ? ((await displayName(ctx.user)) ?? ctx.user) : ctx.user;
|
|
273
277
|
// (sprint 88, GEN-4; APEX: the page footer) under the content: the application's version and "Back to top" (to the header, #top)
|
|
@@ -280,6 +284,7 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
280
284
|
<span class="t-spacer"></span>
|
|
281
285
|
${ctx.app.pwa ? html`<button type="button" class="icon-button t-install" data-pwa-install hidden title="${t('pwa.install')}" aria-label="${t('pwa.install')}">${icon('download')}</button>` : ''}
|
|
282
286
|
${navbar}
|
|
287
|
+
${bell}
|
|
283
288
|
${ctx.app.authentication !== 'none'
|
|
284
289
|
? signedIn
|
|
285
290
|
? html`<details class="menu t-user">
|
|
@@ -314,7 +319,11 @@ export async function chrome(ctx: PageContext, main: Raw, title: string) {
|
|
|
314
319
|
</div>
|
|
315
320
|
${devbarHtml(ctx)}`,
|
|
316
321
|
`t-app${topNav ? ' nav-top' : ''}${appearance ? ` nav-pillar nav-${appearance === 'pillar_dark' ? 'pillar-dark' : 'pillar-light'}` : ''}`,
|
|
317
|
-
{
|
|
322
|
+
{
|
|
323
|
+
'data-base': ctx.base, 'data-page': String(ctx.page.page_no), ...pwaBody(ctx.app, ctx.user), ...(await pushBody(ctx.app, ctx.session.username)),
|
|
324
|
+
// (154) live badges: the page asks for its badges again every so many seconds
|
|
325
|
+
...(ctx.app.live_badges_s ? { 'data-live': String(ctx.app.live_badges_s) } : {}),
|
|
326
|
+
},
|
|
318
327
|
html`${pageStyle(ctx)}${pwaHead(ctx.app)}${mapHead(ctx)}${staticHead(ctx)}`,
|
|
319
328
|
root,
|
|
320
329
|
);
|
package/src/runtime/routes.ts
CHANGED
|
@@ -229,13 +229,21 @@ export const txContext = (ctx: PageContext) => ({
|
|
|
229
229
|
});
|
|
230
230
|
|
|
231
231
|
/** Load app, page, session and user; handles 404 and the login redirect. */
|
|
232
|
-
export async function loadContext(
|
|
232
|
+
export async function loadContext(
|
|
233
|
+
req: Req,
|
|
234
|
+
reply: FastifyReply,
|
|
235
|
+
{ json = false, pageNo: fixedPage, touch = true }: { json?: boolean; pageNo?: 'home'; touch?: boolean } = {},
|
|
236
|
+
): Promise<PageContext | null> {
|
|
233
237
|
const app = await loadApp(req.params.alias);
|
|
234
238
|
if (!app) return simplePage(reply, 404, english('error.not_found'), english('error.app_not_found', { app: req.params.alias })), null;
|
|
235
239
|
const base = `/a/${app.alias}`;
|
|
236
|
-
|
|
240
|
+
// (154) touch false: a request the page makes by itself (live badges) neither keeps an idle session
|
|
241
|
+
// alive nor starts one (no sign-in from a remembered browser either)
|
|
242
|
+
const existing = touch ? await getSession(req, reply, app.id, base) : await getSession(req, reply, app.id, base, { touch: false });
|
|
243
|
+
if (!existing) return reply.code(401).send({ error: english('error.session_ended') }), null;
|
|
244
|
+
let session = existing;
|
|
237
245
|
// the session ended, but the browser was remembered: sign in again silently (a new token each time)
|
|
238
|
-
if (app.authentication === 'app_users' && !session.username) {
|
|
246
|
+
if (touch && app.authentication === 'app_users' && !session.username) {
|
|
239
247
|
const remembered = await useRemember(req, reply, app);
|
|
240
248
|
// (sprint 130) one made before the application asked this user for a second step doesn't stand in for it
|
|
241
249
|
if (remembered && (await carriesSecondStep(app, remembered.username, remembered.roles, remembered.method)))
|
|
@@ -245,7 +253,7 @@ export async function loadContext(req: Req, reply: FastifyReply, { json = false,
|
|
|
245
253
|
});
|
|
246
254
|
}
|
|
247
255
|
// (067) another application of the session sharing group was signed in to: sign in here too
|
|
248
|
-
if (!session.username && sharingGroup(app)) {
|
|
256
|
+
if (touch && !session.username && sharingGroup(app)) {
|
|
249
257
|
const shared = await useShared(req, reply, app);
|
|
250
258
|
if (shared && (await carriesSecondStep(app, shared.username, shared.roles, shared.method)))
|
|
251
259
|
session = await signIn(req, reply, app, session, shared.username, {
|
package/src/runtime/signin.ts
CHANGED
|
@@ -19,7 +19,8 @@ import { languagePicker } from './render.ts';
|
|
|
19
19
|
|
|
20
20
|
/** Where to go after signing in: a path of this application, else its home page. */
|
|
21
21
|
export const safeNext = (app: App, next: string | undefined) =>
|
|
22
|
-
|
|
22
|
+
// (sprint 165) no dot segments either: /a/<alias>/../../builder stays inside the application
|
|
23
|
+
next && next.startsWith(`/a/${app.alias}/`) && !/[\\\r\n]/.test(next) && !next.includes('//') && !/\/(\.|%2e){1,2}(\/|$|\?|#)/i.test(next) ? next : `/a/${app.alias}/${app.home_page}`;
|
|
23
24
|
|
|
24
25
|
/** "Forgot password?" is offered: turned on, app users with passwords, an enabled mail service. */
|
|
25
26
|
export async function passwordResetAvailable(app: App) {
|
package/src/session.ts
CHANGED
|
@@ -53,10 +53,11 @@ export const cookieName = (appId: number | null) => (appId === null ? 'pgkiln_de
|
|
|
53
53
|
/** The idle and maximum session length (minutes, hours), for lookups outside this file. */
|
|
54
54
|
export const sessionLimits = () => ({ idleMinutes: IDLE_MINUTES(), maxHours: MAX_HOURS() });
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
/** A live session of the token; touch (the default): the request counts as activity (last_seen). */
|
|
57
|
+
async function find(token: string | undefined, appId: number | null, touch = true) {
|
|
57
58
|
if (!token || token.length > 100) return undefined;
|
|
58
59
|
return runtime.one<Session>(
|
|
59
|
-
`update meta.session set last_seen = now()
|
|
60
|
+
`update meta.session set last_seen = ${touch ? 'now()' : 'last_seen'}
|
|
60
61
|
where token_hash = $1 and app_id is not distinct from $2
|
|
61
62
|
and last_seen > now() - make_interval(mins => $3)
|
|
62
63
|
and created_at > now() - make_interval(hours => $4)
|
|
@@ -84,8 +85,16 @@ export async function createSession(reply: FastifyReply, appId: number | null, p
|
|
|
84
85
|
return { ...s, isNew: true };
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
88
|
+
/**
|
|
89
|
+
* The request's session, or a new one. (154) touch false: a request the browser makes by itself (live
|
|
90
|
+
* badges) doesn't keep an idle session alive, and makes no new session when there is none (null).
|
|
91
|
+
*/
|
|
92
|
+
export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string): Promise<Session>;
|
|
93
|
+
export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string, opts: { touch: false }): Promise<Session | null>;
|
|
94
|
+
export async function getSession(req: FastifyRequest, reply: FastifyReply, appId: number | null, path: string, opts?: { touch: false }) {
|
|
95
|
+
const found = await find(req.cookies[cookieName(appId)], appId, !opts);
|
|
96
|
+
if (found || opts) return found ?? null;
|
|
97
|
+
return createSession(reply, appId, path);
|
|
89
98
|
}
|
|
90
99
|
|
|
91
100
|
export async function saveState(s: Session) {
|