pgkiln 0.52.0 → 0.54.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/README.md +1 -1
- package/SECURITY.md +6 -1
- package/db/migrations/125_app_change.sql +23 -0
- package/db/migrations/126_mail_queue_network_access.sql +119 -0
- package/docs/apex-feature-parity.md +41 -12
- package/docs/guide/01-installation.md +1 -1
- package/docs/guide/03-builder.md +116 -10
- package/docs/guide/06-processing.md +6 -1
- package/docs/guide/08-security.md +55 -0
- package/docs/guide/09-reference.md +3 -1
- package/docs/guide/11-from-apex.md +1 -0
- package/docs/guide/12-development.md +52 -11
- package/docs/guide/14-globalization.md +49 -1
- package/docs/guide/18-cli.md +21 -1
- package/docs/guide/19-rest-data-sources.md +12 -0
- package/docs/guide/20-ai-agents.md +8 -2
- package/examples/gallery/README.md +37 -6
- package/examples/gallery/gallery.json +23 -1
- package/examples/gallery/samples/sample-calendar.sql +172 -0
- package/examples/gallery/samples/sample-cards.sql +157 -0
- package/examples/gallery/samples/sample-charts.sql +265 -0
- package/examples/gallery/samples/sample-dynamic-actions.sql +177 -0
- package/examples/gallery/samples/sample-grids.sql +151 -0
- package/examples/gallery/samples/sample-reporting.sql +151 -0
- package/examples/gallery/samples/ux-patterns.sql +295 -0
- package/examples/gallery/utilities/announcements.json +24 -0
- package/examples/gallery/utilities/lookup-lists.json +28 -0
- package/examples/gallery/utilities/release-notes.json +41 -0
- package/examples/hr/hr_70_data_security.sql +79 -0
- package/package.json +1 -1
- package/public/app.css +99 -23
- package/public/builder.css +71 -1
- package/public/builder.js +4 -0
- package/src/app.ts +9 -0
- package/src/builder/app-updates.ts +55 -0
- package/src/builder/data-security.ts +182 -0
- package/src/builder/gallery.ts +94 -47
- package/src/builder/home.ts +16 -5
- package/src/builder/mail-queue.ts +212 -0
- package/src/builder/mail.ts +72 -45
- package/src/builder/network-access.ts +140 -0
- package/src/builder/object-actions.ts +1 -0
- package/src/builder/object-browser.ts +1 -1
- package/src/builder/rest-catalogs.ts +19 -4
- package/src/builder/sql.ts +2 -0
- package/src/builder/themeroller.ts +47 -6
- package/src/builder/ui.ts +5 -0
- package/src/builder/websources.ts +1 -1
- package/src/builder/workspace-mail.ts +121 -0
- package/src/builder/workspaces.ts +1 -0
- package/src/cli/main.ts +25 -3
- package/src/cli/mcp.ts +40 -4
- package/src/cli/only.ts +52 -0
- package/src/cli/replace.ts +5 -1
- package/src/datasec.ts +343 -0
- package/src/mail/mail.ts +50 -11
- package/src/mail/providers.ts +6 -3
- package/src/metadata.ts +2 -0
- package/src/network-acl.ts +126 -0
- package/src/objectstore.ts +3 -0
- package/src/runtime/charts.ts +4 -1
- package/src/runtime/engine.ts +3 -1
- package/src/runtime/regions.ts +8 -5
- package/src/runtime/render.ts +4 -2
- package/src/runtime/styles.ts +54 -12
- package/src/webclient.ts +45 -4
- package/src/webrequests.ts +5 -0
- package/src/websources.ts +30 -12
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply } from 'fastify';
|
|
2
|
+
import { html, raw, type Raw } from '../html.ts';
|
|
3
|
+
import { icon } from '../icons.ts';
|
|
4
|
+
import { owner } from '../db.ts';
|
|
5
|
+
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
6
|
+
import { back, BASE, csrf, developer, flash, send, shell, workshopTabs, type Body, type Req } from './ui.ts';
|
|
7
|
+
import { SYSTEM_SCHEMAS } from './object-browser.ts';
|
|
8
|
+
import { clearCompletions } from './code-editor.ts';
|
|
9
|
+
import { roleHints, splitRoles } from './users.ts';
|
|
10
|
+
import { currentWorkspace, inWorkspaceSql, usableSchemas, workshopDb } from './workspaces.ts';
|
|
11
|
+
import { joinStatements, recordChanges } from '../changelog.ts';
|
|
12
|
+
import {
|
|
13
|
+
APP_ROLE_RE, checkSecurity, currentDef, MASK_LABELS, MASKS, maskedColumns, readSecurity, securitySql, sees,
|
|
14
|
+
type ColumnMask, type SecurityDef, type TableSecurity,
|
|
15
|
+
} from '../datasec.ts';
|
|
16
|
+
|
|
17
|
+
// (sprint 119, WS-13: data security per table) SQL Workshop › Object Browser › a table › Security: the table's row
|
|
18
|
+
// level security policies, a matrix of database roles × columns (read, update) and masked values per application
|
|
19
|
+
// role, in one form. Preview SQL shows the GRANT / REVOKE statements and the masking view (src/datasec.ts); Apply runs
|
|
20
|
+
// them in one transaction with the workshop's connection and records a change set; Open in the worksheet puts them
|
|
21
|
+
// there. Like Table Properties, the statements are made against the catalog as it is at the time of the post.
|
|
22
|
+
|
|
23
|
+
/** The workspace's applications with a database role, and the application roles they check. */
|
|
24
|
+
async function workspaceApps(s: Session) {
|
|
25
|
+
const apps = (await owner.query<{ id: number; alias: string; role: string }>(
|
|
26
|
+
`select a.id, a.alias, a.db_role as role from meta.app a where a.db_role is not null and ${inWorkspaceSql('a', 1)} order by a.alias`,
|
|
27
|
+
[currentWorkspace(s)])).rows;
|
|
28
|
+
const hints = await roleHints(apps.map((a) => a.id));
|
|
29
|
+
const appRoles = [...new Set([...hints.values()].flat().map((h) => h.role))].filter((r) => APP_ROLE_RE.test(r));
|
|
30
|
+
return { appDbRoles: apps.map((a) => ({ role: a.role, alias: a.alias })), appRoles };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** The definition the form posted. Grant rows and columns are matched by index against the catalog's state. */
|
|
34
|
+
function fromForm(b: Body, cur: TableSecurity): SecurityDef {
|
|
35
|
+
const on = (k: string) => b[k] === 'on';
|
|
36
|
+
const all = cur.columns.map((c) => c.name);
|
|
37
|
+
const grants = cur.grants.map((g, i) => ({
|
|
38
|
+
role: g.role,
|
|
39
|
+
select: all.filter((_, j) => on(`g.${i}.s.${j}`)),
|
|
40
|
+
update: all.filter((_, j) => on(`g.${i}.u.${j}`)),
|
|
41
|
+
}));
|
|
42
|
+
// the roles the matrix's checkboxes were drawn for (a changed list applies after the next Preview)
|
|
43
|
+
const shown = splitRoles(String(b.shown_roles ?? ''));
|
|
44
|
+
const roles = splitRoles(String(b.roles ?? ''));
|
|
45
|
+
const cols: Record<string, ColumnMask> = {};
|
|
46
|
+
all.forEach((c, j) => {
|
|
47
|
+
const mask = MASKS.find((m) => m === b[`m.${j}.mask`]);
|
|
48
|
+
if (!mask) return;
|
|
49
|
+
cols[c] = { mask, others: on(`m.${j}.others`), see: shown.filter((r, k) => roles.includes(r) && on(`m.${j}.see.${k}`)) };
|
|
50
|
+
});
|
|
51
|
+
return {
|
|
52
|
+
grants,
|
|
53
|
+
view: { enabled: on('view'), name: String(b.view_name ?? '').trim(), roles, cols },
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function form(s: Session, cur: TableSecurity, def: SecurityDef, opts: { ref: string; sql: string[]; warnings: string[]; errors: string[]; output: Raw | '' }): Raw {
|
|
58
|
+
const chk = (x: boolean) => (x ? raw(' checked') : '');
|
|
59
|
+
const all = cur.columns.map((c) => c.name);
|
|
60
|
+
const v = def.view;
|
|
61
|
+
const masked = new Set(maskedColumns(v));
|
|
62
|
+
const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref })}`;
|
|
63
|
+
const policyUrl = `${BASE}/sql/objects/action?${new URLSearchParams({ schema: cur.schema, k: 'table', o: opts.ref, do: 'policy' })}`;
|
|
64
|
+
const rows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Database roles and columns</caption>
|
|
65
|
+
<thead><tr><th scope="col">Database role</th>${all.map((c) => html`<th scope="col"><code>${c}</code>${masked.has(c) ? html` <span class="badge" title="Masked in ${v.name}">masked</span>` : ''}</th>`)}</tr></thead>
|
|
66
|
+
<tbody>${cur.grants.length ? def.grants.map((g, i) => {
|
|
67
|
+
const info = cur.grants[i];
|
|
68
|
+
return html`<tr><th scope="row"><code>${g.role === 'PUBLIC' ? 'PUBLIC' : g.role}</code>${info.apps.length ? html`<br><small class="muted">runs ${info.apps.join(', ')}</small>` : ''}</th>
|
|
69
|
+
${all.map((c, j) => html`<td class="ds-cell"><label class="check"><input type="checkbox" name="g.${i}.s.${j}"${chk(g.select.includes(c))}> read</label>
|
|
70
|
+
<label class="check"><input type="checkbox" name="g.${i}.u.${j}"${chk(g.update.includes(c))}> update</label></td>`)}</tr>`;
|
|
71
|
+
}) : html`<tr><td colspan="${all.length + 1}" class="muted">No database role has privileges on this table, and no application of the workspace runs with a database role.</td></tr>`}</tbody></table></div>
|
|
72
|
+
${cur.excluded.length ? html`<p class="muted small">Not in the matrix, because column privileges can't restrict them: ${cur.excluded.map((x) => html`<code>${x.role}</code> (${x.why}) `)}</p>` : ''}`;
|
|
73
|
+
const maskRows = html`<div class="table-wrap"><table class="report ds-table"><caption class="sr-only">Masks per column</caption>
|
|
74
|
+
<thead><tr><th scope="col">Column</th><th scope="col">Mask</th>${v.roles.map((r) => html`<th scope="col">${r} sees it</th>`)}<th scope="col">Everyone else sees it</th></tr></thead>
|
|
75
|
+
<tbody>${all.map((c, j) => {
|
|
76
|
+
const m = v.cols[c];
|
|
77
|
+
return html`<tr><th scope="row"><code>${c}</code> <small class="muted">${cur.columns[j].type}</small></th>
|
|
78
|
+
<td><select name="m.${j}.mask" aria-label="Mask of ${c}"><option value="">Not masked</option>${MASKS.map((x) => html`<option value="${x}"${m?.mask === x ? raw(' selected') : ''}>${MASK_LABELS[x]}</option>`)}</select></td>
|
|
79
|
+
${v.roles.map((r, k) => html`<td class="ds-c"><input type="checkbox" aria-label="${r} sees ${c}" name="m.${j}.see.${k}"${chk(!!m && sees(v, c, r))}></td>`)}
|
|
80
|
+
<td class="ds-c"><input type="checkbox" aria-label="Everyone else sees ${c}" name="m.${j}.others"${chk(!!m && m.others)}></td></tr>`;
|
|
81
|
+
})}</tbody></table></div>`;
|
|
82
|
+
return html`<form method="post" action="${BASE}/sql/objects/security" class="ds">${csrf(s)}
|
|
83
|
+
<input type="hidden" name="ref" value="${opts.ref}"><input type="hidden" name="shown_roles" value="${v.roles.join(', ')}">
|
|
84
|
+
${opts.errors.length ? html`<div class="alert alert-error" role="alert"><ul>${opts.errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : ''}
|
|
85
|
+
<section class="ds-section"><h2>${icon('shield')} Rows</h2>
|
|
86
|
+
<p>Row level security is <strong>${cur.rls ? (cur.forceRls ? 'on, also for the owner' : 'on') : 'off'}</strong>${cur.rls ? '' : ': every role that may read the table reads every row'}.
|
|
87
|
+
<a href="${BASE}/sql/objects/table?${new URLSearchParams({ o: opts.ref })}">Table Properties</a> switches it on; <a href="${policyUrl}">Add policy</a> writes one in the worksheet.</p>
|
|
88
|
+
${cur.policies.length ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Policy</th><th>Command</th><th>Roles</th><th>Using</th><th>With check</th></tr></thead><tbody>
|
|
89
|
+
${cur.policies.map((p) => html`<tr><td>${p.name}${p.permissive === 'RESTRICTIVE' ? html` <span class="badge">restrictive</span>` : ''}</td><td>${p.command}</td><td><code>${p.roles}</code></td><td><code>${p.using ?? ''}</code></td><td><code>${p.check ?? ''}</code></td></tr>`)}</tbody></table></div>`
|
|
90
|
+
: html`<p class="muted">No policies${cur.rls ? ': with row level security on, the roles below read no rows at all' : ''}.</p>`}
|
|
91
|
+
</section>
|
|
92
|
+
<section class="ds-section"><h2>${icon('table')} Columns</h2>
|
|
93
|
+
<p class="muted">What each database role may read and update, per column. A column a role may not read is refused by PostgreSQL whoever sends the query: pages, REST handlers, AutoREST, AI tools, MCP or psql.</p>
|
|
94
|
+
${rows}
|
|
95
|
+
</section>
|
|
96
|
+
<section class="ds-section"><h2>${icon('eye')} Values</h2>
|
|
97
|
+
<p class="muted">Masked columns show their value only to the application roles you tick (<code>meta.has_role()</code> of the signed-in user); others see the mask. The masks live in a view next to the table that the roles above read instead of the table.</p>
|
|
98
|
+
<label class="check"><input type="checkbox" name="view"${chk(v.enabled)}> Masking view</label>
|
|
99
|
+
<div class="form-grid">
|
|
100
|
+
<div class="field"><label class="label" for="ds-view">View name</label><input id="ds-view" name="view_name" value="${v.name}" autocomplete="off" spellcheck="false"></div>
|
|
101
|
+
<div class="field"><label class="label" for="ds-roles">Application roles</label><input id="ds-roles" name="roles" value="${v.roles.join(', ')}" autocomplete="off" spellcheck="false">
|
|
102
|
+
<small class="help">Comma-separated. Changed roles get their column in the table below after Preview SQL.</small></div>
|
|
103
|
+
</div>
|
|
104
|
+
${maskRows}
|
|
105
|
+
</section>
|
|
106
|
+
<section class="ds-section"><h2>${icon('code')} SQL</h2>
|
|
107
|
+
${opts.warnings.length ? html`<div class="alert alert-warning" role="status"><ul>${opts.warnings.map((w) => html`<li>${w}</li>`)}</ul></div>` : ''}
|
|
108
|
+
${opts.sql.length ? html`<pre class="source" data-code-view="sql" tabindex="0" aria-label="SQL">${opts.sql.join(';\n\n')};</pre>` : html`<p class="muted">No changes: the table's security is as the form shows it.</p>`}
|
|
109
|
+
${opts.output}
|
|
110
|
+
</section>
|
|
111
|
+
<div class="buttons">
|
|
112
|
+
<button class="btn" name="mode" value="preview">${icon('eye')} Preview SQL</button>
|
|
113
|
+
<button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>
|
|
114
|
+
<button class="btn" name="mode" value="worksheet">${icon('code')} Open in the worksheet</button>
|
|
115
|
+
<a class="btn" href="${objUrl}">Cancel</a>
|
|
116
|
+
</div></form>`;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function page(req: Req, reply: FastifyReply) {
|
|
120
|
+
const s = await developer(req, reply);
|
|
121
|
+
if (!s) return;
|
|
122
|
+
const db = await workshopDb(s);
|
|
123
|
+
const isPost = req.method === 'POST';
|
|
124
|
+
const b: Body = isPost ? (req.body ?? {}) : (req.query ?? {});
|
|
125
|
+
const ref = String((isPost ? b.ref : b.o) ?? '');
|
|
126
|
+
const schemas = (await usableSchemas(db)).filter((n) => !SYSTEM_SCHEMAS.includes(n));
|
|
127
|
+
const { appDbRoles, appRoles } = await workspaceApps(s);
|
|
128
|
+
const cur = ref ? await readSecurity(db, ref, appDbRoles, appRoles) : null;
|
|
129
|
+
if (!cur || !schemas.includes(cur.schema)) return reply.code(404).send('Not found');
|
|
130
|
+
let def = currentDef(cur);
|
|
131
|
+
let errors: string[] = [];
|
|
132
|
+
let sql: string[] = [];
|
|
133
|
+
let warnings: string[] = [];
|
|
134
|
+
let output: Raw | '' = '';
|
|
135
|
+
if (isPost) {
|
|
136
|
+
def = fromForm(b, cur);
|
|
137
|
+
const taken = (await db.query<{ n: string }>(`select relname as n from pg_class where relnamespace = $1::regnamespace`, [cur.schema])).rows.map((r) => r.n);
|
|
138
|
+
errors = checkSecurity(def, cur, taken);
|
|
139
|
+
if (!errors.length) ({ statements: sql, warnings } = securitySql(def, cur));
|
|
140
|
+
if (!errors.length && b.mode === 'worksheet') {
|
|
141
|
+
s.state.__SQLDRAFT = sql.length ? `${sql.join(';\n\n')};` : '-- no changes';
|
|
142
|
+
flash(s, 'Review the statements and run them: nothing has been changed yet.');
|
|
143
|
+
return back(reply, s, `${BASE}/sql/worksheet`);
|
|
144
|
+
}
|
|
145
|
+
if (!errors.length && b.mode === 'apply' && sql.length) {
|
|
146
|
+
const c = await db.pool.connect();
|
|
147
|
+
let failed = '';
|
|
148
|
+
let at = '';
|
|
149
|
+
try {
|
|
150
|
+
await c.query('begin');
|
|
151
|
+
for (const x of sql) {
|
|
152
|
+
at = x;
|
|
153
|
+
await c.query(x);
|
|
154
|
+
}
|
|
155
|
+
await c.query('commit');
|
|
156
|
+
} catch (e) {
|
|
157
|
+
failed = (e as Error).message;
|
|
158
|
+
await c.query('rollback').catch(() => {});
|
|
159
|
+
} finally {
|
|
160
|
+
c.release(true);
|
|
161
|
+
}
|
|
162
|
+
await logActivity({ username: s.username, event: 'sql_command', ip: clientIp(req), detail: sql.join(';\n').slice(0, 2000) });
|
|
163
|
+
clearCompletions();
|
|
164
|
+
if (!failed) {
|
|
165
|
+
await recordChanges({ workspaceId: currentWorkspace(s), author: s.username ?? '?', source: 'table', name: `security_${cur.table}`.slice(0, 63), defaultSchema: cur.schema, statements: [joinStatements(sql)] });
|
|
166
|
+
flash(s, `Changed the security of ${cur.schema}.${cur.table}: ${sql.length} statement${sql.length === 1 ? '' : 's'}.`);
|
|
167
|
+
return back(reply, s, `${BASE}/sql/objects/security?${new URLSearchParams({ o: ref })}`);
|
|
168
|
+
}
|
|
169
|
+
output = html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p><pre class="source" data-code-view="sql" tabindex="0" aria-label="Failed statement">${at}</pre></div>`;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const title = `Security of ${cur.schema}.${cur.table}`;
|
|
173
|
+
const objUrl = `${BASE}/sql/objects?${new URLSearchParams({ schema: cur.schema, k: 'table', o: ref })}`;
|
|
174
|
+
const main = html`<h1 class="u-mb1">${title}</h1>${workshopTabs('objects')}${form(s, cur, def, { ref, sql, warnings, errors, output })}`;
|
|
175
|
+
return send(reply, s, shell(s, title, [['SQL Workshop', `${BASE}/sql`], ['Object Browser', `${BASE}/sql/objects`], [`${cur.schema}.${cur.table}`, objUrl], ['Security']], main, 'sql'));
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export async function dataSecurityRoutes(app: FastifyInstance) {
|
|
179
|
+
app.get(`${BASE}/sql/objects/security`, page);
|
|
180
|
+
app.post(`${BASE}/sql/objects/security`, page);
|
|
181
|
+
}
|
|
182
|
+
|
package/src/builder/gallery.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { readFileSync } from 'node:fs';
|
|
2
2
|
import { join } from 'node:path';
|
|
3
3
|
import type { FastifyInstance } from 'fastify';
|
|
4
|
-
import { owner, ownerUrl } from '../db.ts';
|
|
4
|
+
import { owner, ownerUrl, type Client } from '../db.ts';
|
|
5
5
|
import { root } from '../env.ts';
|
|
6
6
|
import { html, raw } from '../html.ts';
|
|
7
7
|
import { icon } from '../icons.ts';
|
|
@@ -13,16 +13,21 @@ import { back, BASE, csrf, developer, flash, send, shell, type Req } from './ui.
|
|
|
13
13
|
import { currentWorkspace, inWorkspaceSql, placeApp } from './workspaces.ts';
|
|
14
14
|
import { workspacesChanged } from '../workspace-db.ts';
|
|
15
15
|
|
|
16
|
-
// The App Gallery (sprint 61; APEX: App Gallery with Sample, Starter and
|
|
16
|
+
// The App Gallery (sprint 61, sprint 120; APEX: App Gallery with Sample, Starter, Utility and
|
|
17
|
+
// Custom Apps).
|
|
17
18
|
//
|
|
18
|
-
// Sample apps applications shipped with pgkiln
|
|
19
|
-
//
|
|
19
|
+
// Sample apps applications shipped with pgkiln: an administrator installs one with its
|
|
20
|
+
// schema, data and demo account, either an example directory (`example`, as
|
|
21
|
+
// `pgkiln migrate --example`) or one SQL file (`file`: examples/*.sql or
|
|
22
|
+
// examples/gallery/samples/*.sql, run in one transaction as the owner);
|
|
20
23
|
// Starter apps blueprints shipped with pgkiln (examples/gallery/starters/): Install opens
|
|
21
24
|
// the blueprint's review, and the developer creates the application from it
|
|
22
25
|
// in the current workspace (a new role and schema, like every new application);
|
|
26
|
+
// Utility apps (sprint 120) blueprints too (examples/gallery/utilities/), of applications a
|
|
27
|
+
// workspace uses rather than learns from, installed the same way;
|
|
23
28
|
// Custom apps the workspace's boilerplate applications: Create application starts from one.
|
|
24
29
|
//
|
|
25
|
-
// The list is examples/gallery/gallery.json.
|
|
30
|
+
// The list is examples/gallery/gallery.json; requests only ever name a key of it.
|
|
26
31
|
|
|
27
32
|
interface Sample {
|
|
28
33
|
key: string;
|
|
@@ -44,64 +49,94 @@ interface Starter {
|
|
|
44
49
|
const GALLERY = join(root, 'examples', 'gallery');
|
|
45
50
|
const KEY = /^[a-z][a-z0-9-]{0,40}$/;
|
|
46
51
|
|
|
47
|
-
|
|
52
|
+
const list = <T>(v: unknown): T[] => (Array.isArray(v) ? v : []);
|
|
53
|
+
|
|
54
|
+
function catalog(): { samples: Sample[]; starters: Starter[]; utilities: Starter[] } {
|
|
48
55
|
try {
|
|
49
56
|
const c = JSON.parse(readFileSync(join(GALLERY, 'gallery.json'), 'utf8'));
|
|
50
|
-
return { samples:
|
|
57
|
+
return { samples: list(c.samples), starters: list(c.starters), utilities: list(c.utilities) };
|
|
51
58
|
} catch {
|
|
52
|
-
return { samples: [], starters: [] };
|
|
59
|
+
return { samples: [], starters: [], utilities: [] };
|
|
53
60
|
}
|
|
54
61
|
}
|
|
55
62
|
|
|
63
|
+
/** The blueprint files a kind of entry may name: starters/x.json or utilities/x.json. */
|
|
64
|
+
const BLUEPRINT_FILE = { starters: /^starters\/[a-z0-9-]+\.json$/, utilities: /^utilities\/[a-z0-9-]+\.json$/ } as const;
|
|
65
|
+
/** The SQL files a sample may name: examples/x.sql or examples/gallery/samples/x.sql. */
|
|
66
|
+
const SAMPLE_FILE = /^(samples\/)?[a-z0-9-]+\.sql$/;
|
|
67
|
+
|
|
56
68
|
/** A file of the gallery: only names from the catalogue, never a path from the request. */
|
|
57
69
|
const galleryFile = (rel: string) => readFileSync(join(rel.includes('/') ? GALLERY : join(root, 'examples'), rel), 'utf8');
|
|
58
70
|
|
|
59
|
-
type Tab = 'samples' | 'starters' | 'custom';
|
|
71
|
+
type Tab = 'samples' | 'starters' | 'utilities' | 'custom';
|
|
72
|
+
const TABS: readonly [Tab, string][] = [['samples', 'Sample Apps'], ['starters', 'Starter Apps'], ['utilities', 'Utility Apps'], ['custom', 'Custom Apps']];
|
|
60
73
|
|
|
61
74
|
export async function galleryRoutes(app: FastifyInstance) {
|
|
62
75
|
app.get(`${BASE}/gallery`, async (req: Req, reply) => {
|
|
63
76
|
const s = await developer(req, reply);
|
|
64
77
|
if (!s) return;
|
|
65
|
-
const tab: Tab = ([
|
|
78
|
+
const tab: Tab = TABS.map(([t]) => t).find((t) => t === req.query?.tab) ?? 'samples';
|
|
79
|
+
const q = typeof req.query?.q === 'string' ? req.query.q.trim().slice(0, 100) : '';
|
|
66
80
|
const admin = await isAdmin(s.username);
|
|
67
|
-
const
|
|
81
|
+
const ws = currentWorkspace(s);
|
|
82
|
+
const { samples, starters, utilities } = catalog();
|
|
83
|
+
// an installed sample: Edit only in its own workspace
|
|
68
84
|
const installed = new Map(
|
|
69
|
-
(await owner.query<{ alias: string; id: number }>(
|
|
85
|
+
(await owner.query<{ alias: string; id: number; here: boolean }>(`select a.alias, a.id, ${inWorkspaceSql('a', 2)} as here from meta.app a where a.alias = any($1)`,
|
|
86
|
+
[samples.map((x) => x.alias), ws])).rows.map((r) => [r.alias, r]),
|
|
70
87
|
);
|
|
71
88
|
const custom = (await owner.query<{ id: number; name: string; description: string | null }>(
|
|
72
89
|
`select a.id, a.name, null::text as description from meta.app a where a.app_type = 'boilerplate' and ${inWorkspaceSql('a', 1)} order by lower(a.name), a.id`,
|
|
73
|
-
[
|
|
90
|
+
[ws],
|
|
74
91
|
)).rows;
|
|
92
|
+
// (sprint 120) the search also works without JavaScript: ?q= leaves out what doesn't match
|
|
93
|
+
// (like the script's filter: every word of the search in the title or description)
|
|
94
|
+
const words = q.toLowerCase().split(/\s+/).filter(Boolean);
|
|
95
|
+
const matches = (x: { title?: string; name?: string; description?: string | null }) => {
|
|
96
|
+
const text = `${x.title ?? x.name ?? ''} ${x.description ?? ''}`.toLowerCase();
|
|
97
|
+
return words.every((w) => text.includes(w));
|
|
98
|
+
};
|
|
99
|
+
const counts: Record<Tab, number> = { samples: samples.length, starters: starters.length, utilities: utilities.length, custom: custom.length };
|
|
75
100
|
const card = (ic: string, title: string, by: string, text: string, action: unknown) =>
|
|
76
101
|
html`<article class="gal-card" data-filter-row><div class="gal-head"><span class="gal-icon">${icon(ic)}</span><div><h2>${title}</h2><span class="muted small">${by}</span></div></div>
|
|
77
102
|
<p>${text}</p><div class="gal-foot">${action}</div></article>`;
|
|
78
|
-
const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${
|
|
79
|
-
html`<a href="${BASE}/gallery?tab=${t}"${tab === t ? raw(' aria-current="page"') : ''}>${l}</a>`)}</nav>`;
|
|
103
|
+
const tabs = html`<nav class="gal-tabs segmented" aria-label="Kinds of applications">${TABS.map(([t, l]) =>
|
|
104
|
+
html`<a href="${BASE}/gallery?tab=${t}${q ? `&q=${encodeURIComponent(q)}` : ''}"${tab === t ? raw(' aria-current="page"') : ''}>${l} <span class="gal-count">${counts[t]}</span></a>`)}</nav>`;
|
|
105
|
+
const blueprintCard = (kind: 'starters' | 'utilities') => (x: Starter) => card(x.icon, x.title, 'pgkiln', x.description,
|
|
106
|
+
html`<form method="post" action="${BASE}/gallery/${kind}/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm">Install</button></form>`);
|
|
80
107
|
const cards =
|
|
81
108
|
tab === 'samples'
|
|
82
|
-
? samples.map((x) =>
|
|
83
|
-
installed.
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
? html`<
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
109
|
+
? samples.filter(matches).map((x) => {
|
|
110
|
+
const it = installed.get(x.alias);
|
|
111
|
+
return card(x.icon, x.title, 'pgkiln', x.description,
|
|
112
|
+
it
|
|
113
|
+
? html`<span class="tag tag-ok">Installed</span><a class="btn btn-sm" href="/a/${x.alias}" target="_blank" rel="noopener">Run</a>${it.here
|
|
114
|
+
? html`<a class="btn btn-sm" href="${BASE}/apps/${it.id}">Edit</a>`
|
|
115
|
+
: html`<span class="muted small">in another workspace</span>`}`
|
|
116
|
+
: admin
|
|
117
|
+
? html`<form method="post" action="${BASE}/gallery/samples/${x.key}">${csrf(s)}<button class="btn btn-hot btn-sm" data-confirm="Install ${x.title}? It adds its own schema, data and demo accounts with known passwords: not for a production instance.">Install</button></form>`
|
|
118
|
+
: html`<span class="muted small">An administrator installs it.</span>`);
|
|
119
|
+
})
|
|
120
|
+
: tab === 'starters' || tab === 'utilities'
|
|
121
|
+
? (tab === 'starters' ? starters : utilities).filter(matches).map(blueprintCard(tab))
|
|
122
|
+
: custom.filter(matches).map((x) => card('copy', x.name, 'This workspace', x.description ?? 'A boilerplate application: a new application starts with its pages, shared components and settings.',
|
|
92
123
|
html`<a class="btn btn-hot btn-sm" href="${BASE}/create?boilerplate=${x.id}">Create from it</a><a class="btn btn-sm" href="${BASE}/apps/${x.id}">Edit</a>`));
|
|
93
|
-
const empty =
|
|
94
|
-
? html`<p class="muted">No
|
|
95
|
-
:
|
|
124
|
+
const empty = q && counts[tab]
|
|
125
|
+
? html`<p class="muted">No app matches <strong>${q}</strong>. <a href="${BASE}/gallery?tab=${tab}">Show all</a></p>`
|
|
126
|
+
: tab === 'custom'
|
|
127
|
+
? html`<p class="muted">No custom apps yet: make an application of type <em>Boilerplate</em> (Application settings → Application type) and it appears here.</p>`
|
|
128
|
+
: html`<p class="muted">The gallery is empty.</p>`;
|
|
96
129
|
const intro = {
|
|
97
|
-
samples: 'Applications that show what pgkiln can do, with
|
|
130
|
+
samples: 'Applications that show what pgkiln can do, each with its own schema, data and demo account: the showcase, the tutorial, one app per feature area and the UX Pattern Catalog. An administrator installs them on a development or demo instance.',
|
|
98
131
|
starters: 'Small, ready-to-use applications. Install shows what will be created (tables, pages, sample data) before anything happens; the application gets its own role and schema in this workspace.',
|
|
132
|
+
utilities: 'Applications a workspace uses rather than learns from: shared lookup lists, release notes, announcements. Like starter apps, Install shows what will be created first, and the application gets its own role and schema in this workspace.',
|
|
99
133
|
custom: 'The boilerplate applications of this workspace: start a new application from one.',
|
|
100
134
|
}[tab];
|
|
101
135
|
const main = html`<div class="gal">
|
|
102
136
|
<div class="gal-bar">
|
|
103
|
-
<
|
|
104
|
-
<input
|
|
137
|
+
<form class="ab-search gal-search" role="search" method="get" action="${BASE}/gallery"><label class="sr-only" for="gal-q">Search apps</label>${icon('search')}
|
|
138
|
+
<input type="hidden" name="tab" value="${tab}">
|
|
139
|
+
<input id="gal-q" name="q" type="search" placeholder="Search apps" autocomplete="off" value="${q}" data-filter-list=".gal [data-filter-row]"></form>
|
|
105
140
|
${tabs}
|
|
106
141
|
</div>
|
|
107
142
|
<h1 class="sr-only">App Gallery</h1>
|
|
@@ -120,19 +155,30 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
120
155
|
if (!x) return reply.code(404).send('Not found');
|
|
121
156
|
try {
|
|
122
157
|
if (await owner.one('select 1 as ok from meta.app where alias = $1', [x.alias])) throw new Error(`an application with the alias ${x.alias} exists already.`);
|
|
123
|
-
if (x.example && KEY.test(x.example)) await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
|
|
124
|
-
else if (x.file && /^[a-z0-9-]+\.sql$/.test(x.file)) await owner.query(galleryFile(x.file));
|
|
125
|
-
else throw new Error('the gallery entry names nothing to install.');
|
|
126
|
-
const made = await owner.one<{ id: number; db_role: string | null }>('select id, db_role from meta.app where alias = $1', [x.alias]);
|
|
127
|
-
if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
|
|
128
|
-
// into the current workspace (an isolated one gets the application's role too)
|
|
129
158
|
const ws = currentWorkspace(s);
|
|
130
|
-
|
|
131
|
-
|
|
159
|
+
// into the current workspace (an isolated one gets the application's role too)
|
|
160
|
+
const place = async (c: Client) => {
|
|
161
|
+
const made = (await c.query('select id, db_role from meta.app where alias = $1', [x.alias])).rows[0] as { id: number; db_role: string | null } | undefined;
|
|
162
|
+
if (!made) throw new Error(`the scripts ran, but there is no application ${x.alias}.`);
|
|
163
|
+
if (ws > 1) {
|
|
132
164
|
if (made.db_role && (await c.query('select 1 from meta.workspace where id = $1 and isolated', [ws])).rowCount)
|
|
133
165
|
await c.query('insert into meta.workspace_role (workspace_id, role_name) values ($1, $2) on conflict do nothing', [ws, made.db_role]);
|
|
134
166
|
await placeApp(c, made.id, ws);
|
|
167
|
+
}
|
|
168
|
+
return made;
|
|
169
|
+
};
|
|
170
|
+
let made: { id: number; db_role: string | null };
|
|
171
|
+
if (x.example && KEY.test(x.example)) {
|
|
172
|
+
await migrate({ root, databaseUrl: ownerUrl, example: x.example, log: () => {} });
|
|
173
|
+
made = await owner.tx(place);
|
|
174
|
+
} else if (x.file && SAMPLE_FILE.test(x.file)) {
|
|
175
|
+
// (sprint 120) the file and the placement in one transaction: a failing script leaves nothing behind
|
|
176
|
+
const sql = galleryFile(x.file);
|
|
177
|
+
made = await owner.tx(async (c) => {
|
|
178
|
+
await c.query(sql);
|
|
179
|
+
return place(c);
|
|
135
180
|
});
|
|
181
|
+
} else throw new Error('the gallery entry names nothing to install.');
|
|
136
182
|
await workspacesChanged();
|
|
137
183
|
await logActivity({ appId: made.id, username: s.username, event: 'gallery', ip: clientIp(req), detail: `installed sample app ${x.key}` });
|
|
138
184
|
flash(s, `${x.title} installed (/a/${x.alias}).`);
|
|
@@ -143,12 +189,13 @@ export async function galleryRoutes(app: FastifyInstance) {
|
|
|
143
189
|
}
|
|
144
190
|
});
|
|
145
191
|
|
|
146
|
-
// a starter app: its blueprint's review (nothing is created before the developer confirms there)
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
192
|
+
// a starter or utility app: its blueprint's review (nothing is created before the developer confirms there)
|
|
193
|
+
for (const kind of ['starters', 'utilities'] as const)
|
|
194
|
+
app.post(`${BASE}/gallery/${kind}/:key`, async (req: Req, reply) => {
|
|
195
|
+
const s = await developer(req, reply);
|
|
196
|
+
if (!s) return;
|
|
197
|
+
const x = KEY.test(req.params.key) ? catalog()[kind].find((c) => c.key === req.params.key) : undefined;
|
|
198
|
+
if (!x || typeof x.file !== 'string' || !BLUEPRINT_FILE[kind].test(x.file)) return reply.code(404).send('Not found');
|
|
199
|
+
return openBlueprint(s, reply, x.title, JSON.stringify(JSON.parse(galleryFile(x.file)), null, 2));
|
|
200
|
+
});
|
|
154
201
|
}
|
package/src/builder/home.ts
CHANGED
|
@@ -20,7 +20,7 @@ const SORTS: Record<Sort, string> = {
|
|
|
20
20
|
id: 'a.id',
|
|
21
21
|
alias: 'a.alias',
|
|
22
22
|
pages: 'pages desc, lower(a.name)',
|
|
23
|
-
updated: 'a.updated_at desc nulls last, lower(a.name)',
|
|
23
|
+
updated: 'greatest(a.updated_at, c.updated_at) desc nulls last, lower(a.name)',
|
|
24
24
|
};
|
|
25
25
|
const RECENT_MAX = 8;
|
|
26
26
|
|
|
@@ -45,6 +45,10 @@ export function ago(at: Date | string | null | undefined, now = Date.now()) {
|
|
|
45
45
|
return 'just now';
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
+
/** (116) the App Builder list's working copies count and lock state */
|
|
49
|
+
const copiesText = (n: number) => `${n} working cop${n === 1 ? 'y' : 'ies'}`;
|
|
50
|
+
const lockedText = (by: string) => `Locked by ${by}`;
|
|
51
|
+
|
|
48
52
|
const appIcon = (a: { id: number; name: string }, cls = '') =>
|
|
49
53
|
html`<span class="app-icon app-color-${a.id % APP_COLORS}${cls ? ` ${cls}` : ''}" aria-hidden="true">${a.name.slice(0, 1).toUpperCase()}</span>`;
|
|
50
54
|
|
|
@@ -108,10 +112,14 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
108
112
|
const group = groups.find((g) => String(g.id) === s.state.__BGROUP) ?? null;
|
|
109
113
|
const [apps, total, recent] = await Promise.all([
|
|
110
114
|
owner.query(
|
|
111
|
-
`select a.id, a.name, a.alias, a.authentication, a.db_role, a.updated_at, g.name as group_name,
|
|
115
|
+
`select a.id, a.name, a.alias, a.authentication, a.db_role, greatest(a.updated_at, c.updated_at) as updated_at, g.name as group_name,
|
|
116
|
+
c.updated_by,
|
|
117
|
+
(select count(*) from meta.working_copy w where w.main_app_id = a.id)::int as copies,
|
|
118
|
+
(select locked_by from meta.builder_lock k where k.app_id = a.id and k.page_no = 0) as locked_by,
|
|
112
119
|
(select count(*) from meta.page p where p.app_id = a.id)::int as pages,
|
|
113
120
|
(select count(*) from meta.activity_log l where l.app_id = a.id and l.event = 'page_view' and l.at > now() - interval '1 day')::int as views
|
|
114
121
|
from meta.app a
|
|
122
|
+
left join meta.app_change c on c.app_id = a.id
|
|
115
123
|
left join meta.app_group_member gm on gm.app_id = a.id
|
|
116
124
|
left join meta.app_group g on g.id = gm.group_id
|
|
117
125
|
where ($1 = '' or a.name ilike '%' || $1 || '%' or a.alias ilike '%' || $1 || '%' or a.id::text = $1) and ${inWorkspaceSql('a', 2)}
|
|
@@ -137,22 +145,25 @@ export async function homeRoutes(app: FastifyInstance) {
|
|
|
137
145
|
: view === 'grid'
|
|
138
146
|
? html`<div class="ab-cards">${rows.map((a) => html`<article class="ab-card" data-filter-row>
|
|
139
147
|
<a class="ab-card-main" href="${BASE}/apps/${a.id}">${appIcon(a)}<span><strong>${a.name}</strong><small>${a.id} · /a/${a.alias}</small></span></a>
|
|
140
|
-
<div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}</span></div>
|
|
148
|
+
<div class="ab-card-meta">${a.group_name ? html`<span class="tag">${a.group_name}</span>` : ''}<span>${a.pages} pages</span><span>${a.views} views today</span><span>${ago(a.updated_at)}${a.updated_by ? html` by <span class="ab-by">${a.updated_by}</span>` : ''}</span></div>
|
|
149
|
+
${a.copies || a.locked_by ? html`<div class="ab-card-badges">${a.copies ? html`<a class="tag" href="${BASE}/apps/${a.id}/working-copies">${icon('layers')} ${copiesText(a.copies)}</a>` : ''}${a.locked_by ? html`<span class="tag ab-locked" title="${lockedText(a.locked_by)}">${icon('lock')} <span class="ab-by">${lockedText(a.locked_by)}</span></span>` : ''}</div>` : ''}
|
|
141
150
|
<div class="ab-card-actions">
|
|
142
151
|
<a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
|
|
143
152
|
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
|
|
144
153
|
</div></article>`)}</div>`
|
|
145
154
|
: html`<div class="table-wrap ab-table"><table class="report ab-apps">
|
|
146
155
|
<caption class="sr-only">Applications</caption>
|
|
147
|
-
<thead><tr>${th('id', 'Application', 'num')}${th('name', 'Name')}${th('alias', 'Alias')}${th('pages', 'Pages', 'num')}<th scope="col" class="num">Views (24h)</th><th scope="col">Sign-in</th>${th('updated', 'Updated')}<th scope="col" class="ab-actions-col">Actions</th></tr></thead>
|
|
156
|
+
<thead><tr>${th('id', 'Application', 'num')}${th('name', 'Name')}${th('alias', 'Alias')}${th('pages', 'Pages', 'num')}<th scope="col" class="num">Views (24h)</th><th scope="col">Sign-in</th>${th('updated', 'Updated')}<th scope="col">Updated by</th><th scope="col" class="num">Working copies</th><th scope="col" class="ab-actions-col">Actions</th></tr></thead>
|
|
148
157
|
<tbody>${rows.map((a) => html`<tr data-filter-row>
|
|
149
158
|
<td class="num">${a.id}</td>
|
|
150
|
-
<td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a
|
|
159
|
+
<td class="ab-name"><a href="${BASE}/apps/${a.id}">${appIcon(a)}<span>${a.name}</span></a>${a.locked_by ? html`<span class="ab-lock" role="img" aria-label="${lockedText(a.locked_by)}" title="${lockedText(a.locked_by)}">${icon('lock')}</span>` : ''}</td>
|
|
151
160
|
<td>${a.alias}${a.group_name ? html` <span class="tag">${a.group_name}</span>` : ''}</td>
|
|
152
161
|
<td class="num">${a.pages}</td>
|
|
153
162
|
<td class="num">${a.views}</td>
|
|
154
163
|
<td>${a.authentication === 'none' ? 'Public' : a.authentication === 'header' ? 'HTTP header' : a.authentication === 'database' ? 'Database accounts' : a.authentication === 'custom' ? 'Custom' : 'App users'}</td>
|
|
155
164
|
<td title="${a.updated_at ? new Date(a.updated_at).toISOString().slice(0, 16).replace('T', ' ') : ''}">${ago(a.updated_at)}</td>
|
|
165
|
+
<td class="ab-by">${a.updated_by ?? ''}</td>
|
|
166
|
+
<td class="num">${a.copies ? html`<a href="${BASE}/apps/${a.id}/working-copies" aria-label="${copiesText(a.copies)} of ${a.name}">${a.copies}</a>` : '0'}</td>
|
|
156
167
|
<td class="ab-actions">
|
|
157
168
|
<a class="tb-btn" href="${BASE}/apps/${a.id}" title="Edit ${a.name}">${icon('edit')}<span class="sr-only">Edit ${a.name}</span></a>
|
|
158
169
|
<a class="tb-btn" href="/a/${a.alias}" target="_blank" rel="noopener" title="Run ${a.name}">${icon('play')}<span class="sr-only">Run ${a.name}</span></a>
|