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
|
@@ -13,6 +13,7 @@ import { sourceProblems } from '../websources.ts';
|
|
|
13
13
|
import { back, BASE, csrf, developer, flash, input, region, send, shell, type Body, type Req } from './ui.ts';
|
|
14
14
|
import { currentWorkspace } from './workspaces.ts';
|
|
15
15
|
import { scopeForWorkspace } from '../network-acl.ts';
|
|
16
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
16
17
|
|
|
17
18
|
// Workspace utilities → REST Source Catalogs (migration 095; APEX: Workspace
|
|
18
19
|
// Utilities → REST Source Catalogs): the current workspace's catalogs, a
|
|
@@ -25,7 +26,7 @@ import { scopeForWorkspace } from '../network-acl.ts';
|
|
|
25
26
|
const NOTES = '__CATALOG_NOTES';
|
|
26
27
|
const MAX_DOC_BYTES = 5 * 1024 * 1024;
|
|
27
28
|
const root = `${BASE}/rest-catalogs`;
|
|
28
|
-
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [['
|
|
29
|
+
const crumbs = (extra: [string, string?][] = []): [string, string?][] => [[tx('rcat.app_builder'), BASE], [tx('rcat.workspace_utilities'), `${BASE}/utilities`], ['REST Source Catalogs', root], ...extra];
|
|
29
30
|
const when = (d: string | Date | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
|
|
30
31
|
const pretty = (v: unknown) => JSON.stringify(v ?? null, null, 2);
|
|
31
32
|
|
|
@@ -41,22 +42,22 @@ function notesHtml(s: Session): Raw | '' {
|
|
|
41
42
|
delete s.state[NOTES];
|
|
42
43
|
const notes = JSON.parse(v) as string[];
|
|
43
44
|
return notes.length
|
|
44
|
-
? html`<div class="alert alert-info" role="status"><b
|
|
45
|
+
? html`<div class="alert alert-info" role="status"><b>${tx('rcat.what_the_document_had_that')}</b><ul>${notes.map((n) => html`<li>${n}</li>`)}</ul></div>`
|
|
45
46
|
: '';
|
|
46
47
|
}
|
|
47
48
|
|
|
48
49
|
/** The import form: a file, pasted text or a URL; base URL and name optional. */
|
|
49
50
|
function importForm(s: Session, action: string, replacing: boolean): Raw {
|
|
50
51
|
return html`<form method="post" action="${action}" enctype="multipart/form-data">${csrf(s)}
|
|
51
|
-
<p class="muted u-mt0"
|
|
52
|
+
<p class="muted u-mt0">${tx.rich('rcat.doc_intro', { openapi: html`<b>${tx('rcat.openapi_3')}</b>`, swagger: html`<b>${tx('rcat.swagger_2_0')}</b>`, file: html`<code>openapi.json</code>` })}${replacing ? ` ${tx('rcat.doc_replacing')}` : ''}</p>
|
|
52
53
|
<div class="form-grid">
|
|
53
|
-
<div class="field"><label class="label" for="f_file"
|
|
54
|
-
${input('url', '
|
|
55
|
-
<div class="field" data-wide><label class="label" for="f_text"
|
|
56
|
-
${input('base_url', '
|
|
57
|
-
${replacing ? '' : input('name', '
|
|
54
|
+
<div class="field"><label class="label" for="f_file">${tx('rcat.file')}</label><input id="f_file" type="file" name="file" accept=".json,application/json"></div>
|
|
55
|
+
${input('url', tx('rcat.or_read_it_from_a'), '', { placeholder: tx('rcat.https_api_example_com_openapi'), help: tx('rcat.the_server_reads_it_the') })}
|
|
56
|
+
<div class="field" data-wide><label class="label" for="f_text">${tx('rcat.or_paste_it')}</label><textarea id="f_text" name="text" class="code" rows="4" spellcheck="false"></textarea></div>
|
|
57
|
+
${input('base_url', tx('rcat.base_url'), '', { placeholder: tx('rcat.from_the_document'), help: tx('rcat.instead_of_the_document_s') })}
|
|
58
|
+
${replacing ? '' : input('name', tx('rcat.name'), '', { placeholder: tx('rcat.the_document_s_title') })}
|
|
58
59
|
</div>
|
|
59
|
-
<div class="buttons"><button class="btn btn-hot">${icon('upload')} ${replacing ? '
|
|
60
|
+
<div class="buttons"><button class="btn btn-hot">${icon('upload')} ${replacing ? tx('rcat.replace_the_services') : tx('rcat.import')}</button></div></form>`;
|
|
60
61
|
}
|
|
61
62
|
|
|
62
63
|
/** The text of an uploaded, pasted or fetched document (and where it came from). */
|
|
@@ -66,14 +67,14 @@ async function documentText(req: Req): Promise<{ text: string; from?: string; ur
|
|
|
66
67
|
const parsed = await readMultipart(req, MAX_DOC_BYTES / 1024 / 1024);
|
|
67
68
|
req.body = parsed.body as Body;
|
|
68
69
|
const f = parsed.files.get('file');
|
|
69
|
-
if (f?.truncated) throw new Error('
|
|
70
|
+
if (f?.truncated) throw new Error(tx('rcat.the_file_is_larger_than'));
|
|
70
71
|
if (f) text = f.data.toString('utf8');
|
|
71
72
|
}
|
|
72
73
|
if (!text.trim()) text = String(req.body?.text ?? '');
|
|
73
74
|
if (text.trim()) return { text };
|
|
74
75
|
const url = String(req.body?.url ?? '').trim();
|
|
75
|
-
if (!url) throw new Error('
|
|
76
|
-
if (!/^https?:\/\//i.test(url)) throw new Error('
|
|
76
|
+
if (!url) throw new Error(tx('rcat.choose_a_file_paste_a'));
|
|
77
|
+
if (!/^https?:\/\//i.test(url)) throw new Error(tx('rcat.the_url_starts_with_http'));
|
|
77
78
|
return { text: '', url };
|
|
78
79
|
}
|
|
79
80
|
|
|
@@ -87,7 +88,7 @@ async function documentOf(doc: { text: string; from?: string; url?: string }, s:
|
|
|
87
88
|
headers: { accept: 'application/json' }, timeoutMs: 15_000, maxBytes: MAX_DOC_BYTES,
|
|
88
89
|
scope: await scopeForWorkspace(currentWorkspace(s), 'REST source catalog (read from a URL)'),
|
|
89
90
|
});
|
|
90
|
-
if (r.status !== 200) throw new Error(
|
|
91
|
+
if (r.status !== 200) throw new Error(tx('rcat.url_answered', { status: r.status }));
|
|
91
92
|
return { text: r.body.toString('utf8'), from: r.url };
|
|
92
93
|
}
|
|
93
94
|
|
|
@@ -123,17 +124,17 @@ function serviceForm(s: Session, action: string, v: Partial<CatalogService>, lab
|
|
|
123
124
|
html`<div class="field" data-wide><label class="label" for="f_${name}">${title}</label><textarea id="f_${name}" name="${name}" class="code" rows="${rows}" spellcheck="false">${value}</textarea><small class="help">${help}</small></div>`;
|
|
124
125
|
return html`<form method="post" action="${action}">${csrf(s)}
|
|
125
126
|
<div class="form-grid">
|
|
126
|
-
${input('name', '
|
|
127
|
-
<div class="field"><label class="label" for="f_method"
|
|
128
|
-
${input('path', '
|
|
129
|
-
${input('row_selector', '
|
|
130
|
-
<div class="field" data-wide><label class="label" for="f_description"
|
|
131
|
-
${area('params', '
|
|
132
|
-
${area('columns', '
|
|
133
|
-
${area('headers', '
|
|
134
|
-
${area('body', '
|
|
135
|
-
${input('key_columns', '
|
|
136
|
-
${area('operations', '
|
|
127
|
+
${input('name', tx('rcat.name'), v.name ?? '', { required: true, placeholder: 'GET_PETS', help: tx('rcat.the_data_source_s_name') })}
|
|
128
|
+
<div class="field"><label class="label" for="f_method">${tx('rcat.method')}</label><select id="f_method" name="method">${['GET', 'POST', 'PUT', 'PATCH', 'DELETE'].map((m) => html`<option${(v.method ?? 'GET') === m ? raw(' selected') : ''}>${m}</option>`)}</select></div>
|
|
129
|
+
${input('path', tx('rcat.path'), v.path ?? '', { required: true, placeholder: tx('rcat.pets_pet_id'), help: tx('rcat.after_the_catalog_s_base') })}
|
|
130
|
+
${input('row_selector', tx('rcat.row_selector'), v.row_selector ?? '', { placeholder: 'items', help: tx('rcat.where_the_rows_are_in') })}
|
|
131
|
+
<div class="field" data-wide><label class="label" for="f_description">${tx('rcat.description')}</label><input id="f_description" name="description" value="${v.description ?? ''}"></div>
|
|
132
|
+
${area('params', tx('rcat.parameters_json'), pretty(v.params ?? []), tx('rcat.params_help'))}
|
|
133
|
+
${area('columns', tx('rcat.columns_json'), pretty(v.columns ?? []), '[{"name": "id", "path": "id", "type": "integer"}] · types: text, number, integer, boolean, date, timestamp, json.', 6)}
|
|
134
|
+
${area('headers', tx('rcat.headers_json'), pretty(v.headers ?? {}), tx('rcat.headers_help'), 2)}
|
|
135
|
+
${area('body', tx('rcat.body_template'), v.body ?? '', '{"query": {q}} — empty: the body parameters as a JSON object.', 2)}
|
|
136
|
+
${input('key_columns', tx('rcat.key_columns'), (v.key_columns ?? []).join(', '), { placeholder: 'id' })}
|
|
137
|
+
${area('operations', tx('rcat.operations_json'), pretty(v.operations ?? {}), '{"update": {"method": "PUT", "path": "/{id}"}, "delete": {"method": "DELETE", "path": "/{id}"}} · write-back for forms and grids.', 3)}
|
|
137
138
|
</div>
|
|
138
139
|
<div class="buttons"><button class="btn btn-hot">${icon('save')} ${label}</button></div></form>`;
|
|
139
140
|
}
|
|
@@ -147,24 +148,24 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
147
148
|
`select c.id, c.name, c.description, c.base_url, c.server, c.source, c.api_version, c.updated_at,
|
|
148
149
|
(select count(*)::int from meta.rest_catalog_service x where x.catalog_id = c.id) as services
|
|
149
150
|
from meta.rest_catalog c where c.workspace_id = $1 order by lower(c.name)`, [currentWorkspace(s)])).rows;
|
|
150
|
-
const main = html`<div class="title-row"><h1
|
|
151
|
-
<p class="muted u-mt0"
|
|
151
|
+
const main = html`<div class="title-row"><h1>${tx('rcat.rest_source_catalogs')}</h1></div>
|
|
152
|
+
<p class="muted u-mt0">${tx('rcat.intro', { workspace: s.workspace?.name ?? tx('rcat.this_workspace') })}</p>
|
|
152
153
|
${notesHtml(s)}
|
|
153
|
-
${region('
|
|
154
|
-
<thead><tr><th
|
|
154
|
+
${region(tx('rcat.catalogs'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
155
|
+
<thead><tr><th>${tx('rcat.catalog')}</th><th>${tx('rcat.base_url')}</th><th class="num">${tx('rcat.services')}</th><th>${tx('rcat.from')}</th><th>${tx('rcat.updated_utc')}</th></tr></thead>
|
|
155
156
|
<tbody>${rows.length ? rows.map((c) => html`<tr>
|
|
156
|
-
<td data-label="
|
|
157
|
-
<td data-label="
|
|
158
|
-
<td class="num" data-label="
|
|
159
|
-
<td data-label="
|
|
160
|
-
<td data-label="
|
|
161
|
-
: html`<tr><td colspan="5" class="empty"
|
|
162
|
-
${region('
|
|
163
|
-
${region('
|
|
164
|
-
<div class="form-grid">${input('name', '
|
|
165
|
-
<div class="field" data-wide><label class="label" for="f_cdescription"
|
|
166
|
-
<div class="buttons"><button class="btn">${icon('plus')}
|
|
167
|
-
return send(reply, s, shell(s, '
|
|
157
|
+
<td data-label="${tx('rcat.catalog')}"><a href="${root}/${c.id}">${c.name}</a>${c.description ? html`<br><span class="muted small">${String(c.description).slice(0, 160)}</span>` : ''}</td>
|
|
158
|
+
<td data-label="${tx('rcat.base_url')}">${c.server ? html`<span class="tag">${icon('server')} ${c.server}</span> ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code></td>
|
|
159
|
+
<td class="num" data-label="${tx('rcat.services')}">${c.services}</td>
|
|
160
|
+
<td data-label="${tx('rcat.from')}">${c.source === 'openapi' ? html`OpenAPI${c.api_version ? html` <span class="muted">${c.api_version}</span>` : ''}` : 'by hand'}</td>
|
|
161
|
+
<td data-label="${tx('rcat.updated')}">${when(c.updated_at)}</td></tr>`)
|
|
162
|
+
: html`<tr><td colspan="5" class="empty">${tx('rcat.no_catalogs_yet_import_an')}</td></tr>`}</tbody></table></div>`)}
|
|
163
|
+
${region(tx('rcat.import_a_catalog'), importForm(s, `${root}/import`, false))}
|
|
164
|
+
${region(tx('rcat.create_a_catalog_by_hand'), html`<form method="post" action="${root}">${csrf(s)}
|
|
165
|
+
<div class="form-grid">${input('name', tx('rcat.name'), '', { required: true, placeholder: tx('rcat.e_g_weather_service') })}${input('base_url', tx('rcat.base_url'), '', { required: true, placeholder: tx('rcat.https_api_example_com_v1') })}
|
|
166
|
+
<div class="field" data-wide><label class="label" for="f_cdescription">${tx('rcat.description')}</label><input id="f_cdescription" name="description"></div></div>
|
|
167
|
+
<div class="buttons"><button class="btn">${icon('plus')} ${tx('rcat.create')}</button></div></form>`)}`;
|
|
168
|
+
return send(reply, s, shell(s, tx('rcat.rest_source_catalogs'), crumbs(), main));
|
|
168
169
|
});
|
|
169
170
|
|
|
170
171
|
app.post(root, async (req: Req, reply) => {
|
|
@@ -181,7 +182,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
181
182
|
}
|
|
182
183
|
try {
|
|
183
184
|
const id = await insertCatalog(owner, currentWorkspace(s), c, s.username);
|
|
184
|
-
flash(s, '
|
|
185
|
+
flash(s, tx('rcat.catalog_created_add_its_services'));
|
|
185
186
|
return back(reply, s, `${root}/${id}`);
|
|
186
187
|
} catch (e) {
|
|
187
188
|
flash(s, uniqueError(e), 'error');
|
|
@@ -203,14 +204,14 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
203
204
|
doc = await documentOf(doc, s);
|
|
204
205
|
const { catalog, notes } = readCatalogText(doc.text, { baseUrl: req.body?.base_url, from: doc.from, name: req.body?.name });
|
|
205
206
|
const problems = wholeCatalogProblems(catalog);
|
|
206
|
-
if (problems.length) throw new Error(
|
|
207
|
+
if (problems.length) throw new Error(tx('rcat.unusable', { problems: `${problems.slice(0, 8).join(' ')}${problems.length > 8 ? ` ${tx('rcat.and_more', { n: problems.length - 8 })}` : ''}` }));
|
|
207
208
|
const id = await owner.tx((c) => insertCatalog(c, currentWorkspace(s), catalog, s.username));
|
|
208
209
|
s.state[NOTES] = JSON.stringify(notes);
|
|
209
210
|
await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `imported catalog ${catalog.name} (${catalog.services.length} services)` });
|
|
210
|
-
flash(s,
|
|
211
|
+
flash(s, tx('rcat.imported', { name: catalog.name, n: catalog.services.length }));
|
|
211
212
|
return back(reply, s, `${root}/${id}`);
|
|
212
213
|
} catch (e) {
|
|
213
|
-
flash(s,
|
|
214
|
+
flash(s, tx('rcat.import_failed', { error: uniqueError(e) }), 'error');
|
|
214
215
|
return back(reply, s, root);
|
|
215
216
|
}
|
|
216
217
|
});
|
|
@@ -224,35 +225,35 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
224
225
|
const base = `${root}/${c.id}`;
|
|
225
226
|
const sel = req.query.service ? c.services.find((x) => String(x.id) === req.query.service) : null;
|
|
226
227
|
const main = html`<div class="title-row"><h1>${c.name}</h1>
|
|
227
|
-
<a class="btn" href="${base}/download">${icon('download')}
|
|
228
|
-
<p class="muted u-mt0">${c.server ? html
|
|
228
|
+
<a class="btn" href="${base}/download">${icon('download')} ${tx('rcat.download')}</a></div>
|
|
229
|
+
<p class="muted u-mt0">${c.server ? html`${tx.rich('rcat.on_server', { server: html`<a href="${BASE}/remote-servers">${c.server}</a>` })} · ` : ''}<code class="wrap-anywhere">${c.base_url ?? ''}</code>${c.source === 'openapi' ? html` · ${c.api_version ? tx('rcat.from_openapi_version', { version: c.api_version }) : tx('rcat.from_openapi')}` : ''} · ${tx('rcat.updated_when', { when: when(c.updated_at) })}</p>
|
|
229
230
|
${notesHtml(s)}
|
|
230
|
-
${region('
|
|
231
|
-
<thead><tr><th
|
|
231
|
+
${region(tx('rcat.services'), html`<div class="table-wrap"><table class="report report-reflow">
|
|
232
|
+
<thead><tr><th>${tx('rcat.service')}</th><th>${tx('rcat.method')}</th><th>${tx('rcat.path')}</th><th class="num">${tx('rcat.parameters')}</th><th class="num">${tx('rcat.columns')}</th><th>${tx('rcat.write_back')}</th></tr></thead>
|
|
232
233
|
<tbody>${c.services.length ? c.services.map((x) => html`<tr${sel?.id === x.id ? raw(' aria-current="true"') : ''}>
|
|
233
|
-
<td data-label="
|
|
234
|
-
<td data-label="
|
|
235
|
-
<td data-label="
|
|
236
|
-
<td class="num" data-label="
|
|
237
|
-
<td class="num" data-label="
|
|
238
|
-
<td data-label="
|
|
239
|
-
: html`<tr><td colspan="6" class="empty"
|
|
240
|
-
<p class="u-m0 u-mt1"><a class="btn" href="${base}?service=new#service">${icon('plus')}
|
|
234
|
+
<td data-label="${tx('rcat.service')}"><a href="${base}?service=${x.id}#service">${x.name}</a>${x.description ? html`<br><span class="muted small">${x.description.slice(0, 140)}</span>` : ''}</td>
|
|
235
|
+
<td data-label="${tx('rcat.method')}"><span class="tag">${x.method}</span></td>
|
|
236
|
+
<td data-label="${tx('rcat.path')}"><code class="wrap-anywhere">${x.path}</code></td>
|
|
237
|
+
<td class="num" data-label="${tx('rcat.parameters')}">${x.params.length}</td>
|
|
238
|
+
<td class="num" data-label="${tx('rcat.columns')}">${x.columns.length}</td>
|
|
239
|
+
<td data-label="${tx('rcat.write_back')}">${Object.keys(x.operations).join(', ') || html`<span class="muted">—</span>`}</td></tr>`)
|
|
240
|
+
: html`<tr><td colspan="6" class="empty">${tx('rcat.no_services_yet')}</td></tr>`}</tbody></table></div>
|
|
241
|
+
<p class="u-m0 u-mt1"><a class="btn" href="${base}?service=new#service">${icon('plus')} ${tx('rcat.add_a_service')}</a></p>`)}
|
|
241
242
|
${req.query.service === 'new'
|
|
242
|
-
? region('
|
|
243
|
+
? region(tx('rcat.new_service'), serviceForm(s, `${base}/services`, { method: 'GET', path: '/' }, tx('rcat.add')), '', 'service')
|
|
243
244
|
: sel
|
|
244
|
-
? region(
|
|
245
|
-
${serviceForm(s, `${base}/services/${sel.id}`, sel, '
|
|
246
|
-
<form method="post" action="${base}/services/${sel.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="
|
|
245
|
+
? region(tx('rcat.service_title', { name: sel.name }), html`<p class="muted u-mt0">${tx('rcat.url')} <code class="wrap-anywhere">${c.server ? `${c.server} + ${sel.path}` : serviceUrl(c.base_url ?? '', sel.path)}</code></p>
|
|
246
|
+
${serviceForm(s, `${base}/services/${sel.id}`, sel, tx('rcat.save'))}
|
|
247
|
+
<form method="post" action="${base}/services/${sel.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('rcat.delete_service_confirm', { name: sel.name })}">${tx('rcat.delete_service')}</button></form>`, '', 'service')
|
|
247
248
|
: ''}
|
|
248
|
-
${region('
|
|
249
|
-
<div class="form-grid">${input('name', '
|
|
250
|
-
${input('server', '
|
|
251
|
-
${input('api_version', '
|
|
252
|
-
<div class="field" data-wide><label class="label" for="f_cdescription"
|
|
253
|
-
<div class="buttons"><button class="btn btn-hot">${icon('save')}
|
|
254
|
-
${region('
|
|
255
|
-
<form method="post" action="${base}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="
|
|
249
|
+
${region(tx('rcat.catalog'), html`<form method="post" action="${base}">${csrf(s)}
|
|
250
|
+
<div class="form-grid">${input('name', tx('rcat.name'), c.name, { required: true })}${input('base_url', tx('rcat.base_url'), c.base_url ?? '', { help: tx('rcat.e_g_a_test_system') })}
|
|
251
|
+
${input('server', tx('rcat.remote_server'), c.server ?? '', { placeholder: tx('rcat.e_g_crm'), help: tx('rcat.the_static_id_of_a') })}
|
|
252
|
+
${input('api_version', tx('rcat.api_version'), c.api_version ?? '')}
|
|
253
|
+
<div class="field" data-wide><label class="label" for="f_cdescription">${tx('rcat.description')}</label><textarea id="f_cdescription" name="description" rows="2">${c.description ?? ''}</textarea></div></div>
|
|
254
|
+
<div class="buttons"><button class="btn btn-hot">${icon('save')} ${tx('rcat.save')}</button></div></form>`)}
|
|
255
|
+
${region(tx('rcat.replace_from_a_document'), importForm(s, `${base}/import`, true))}
|
|
256
|
+
<form method="post" action="${base}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('rcat.delete_catalog_confirm', { name: c.name })}">${tx('rcat.delete_catalog')}</button></form>`;
|
|
256
257
|
return send(reply, s, shell(s, c.name, crumbs([[c.name]]), main));
|
|
257
258
|
});
|
|
258
259
|
|
|
@@ -274,9 +275,9 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
274
275
|
await owner.query('update meta.rest_catalog set name = $2, base_url = $3, description = $4, api_version = $5, server = $6, updated_at = now() where id = $1',
|
|
275
276
|
[c.id, v.name, v.base_url || null, v.description, v.api_version, v.server]);
|
|
276
277
|
if (v.server && !(await owner.one('select 1 as ok from meta.remote_server where workspace_id = $1 and static_id = $2', [c.workspace_id, v.server])))
|
|
277
|
-
flash(s,
|
|
278
|
+
flash(s, tx('rcat.no_server', { server: v.server }), 'error');
|
|
278
279
|
else
|
|
279
|
-
flash(s, '
|
|
280
|
+
flash(s, tx('rcat.catalog_saved'));
|
|
280
281
|
} catch (e) {
|
|
281
282
|
flash(s, uniqueError(e), 'error');
|
|
282
283
|
}
|
|
@@ -305,9 +306,9 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
305
306
|
await owner.tx((db) => replaceCatalog(db, c.id, catalog));
|
|
306
307
|
s.state[NOTES] = JSON.stringify(notes);
|
|
307
308
|
await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `replaced catalog ${c.name} (${catalog.services.length} services)` });
|
|
308
|
-
flash(s,
|
|
309
|
+
flash(s, tx('rcat.replaced', { name: c.name, n: catalog.services.length }));
|
|
309
310
|
} catch (e) {
|
|
310
|
-
flash(s,
|
|
311
|
+
flash(s, tx('rcat.import_failed', { error: uniqueError(e) }), 'error');
|
|
311
312
|
}
|
|
312
313
|
return back(reply, s, `${root}/${c.id}`);
|
|
313
314
|
});
|
|
@@ -330,7 +331,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
330
331
|
if (!c) return reply.code(404).send('Not found');
|
|
331
332
|
await owner.query('delete from meta.rest_catalog where id = $1', [c.id]);
|
|
332
333
|
await logActivity({ appId: null, username: s.username, event: 'rest_catalog', ip: clientIp(req), detail: `deleted catalog ${c.name}` });
|
|
333
|
-
flash(s,
|
|
334
|
+
flash(s, tx('rcat.deleted', { name: c.name }));
|
|
334
335
|
return back(reply, s, root);
|
|
335
336
|
});
|
|
336
337
|
|
|
@@ -359,7 +360,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
359
360
|
`insert into meta.rest_catalog_service (catalog_id, name, description, path, method, params, headers, body, row_selector, columns, key_columns, operations, position)
|
|
360
361
|
values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, (select coalesce(max(position), 0) + 10 from meta.rest_catalog_service where catalog_id = $1)) returning id`, [c.id, ...vals])).id;
|
|
361
362
|
await owner.query('update meta.rest_catalog set updated_at = now() where id = $1', [c.id]);
|
|
362
|
-
flash(s,
|
|
363
|
+
flash(s, tx('rcat.service_saved', { name: v.name }));
|
|
363
364
|
return back(reply, s, `${root}/${c.id}?service=${id}#service`);
|
|
364
365
|
} catch (e) {
|
|
365
366
|
flash(s, uniqueError(e), 'error');
|
|
@@ -376,7 +377,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
376
377
|
if (!c) return reply.code(404).send('Not found');
|
|
377
378
|
await owner.query('delete from meta.rest_catalog_service where id = $1 and catalog_id = $2', [req.params.sid, c.id]);
|
|
378
379
|
await owner.query('update meta.rest_catalog set updated_at = now() where id = $1', [c.id]);
|
|
379
|
-
flash(s, '
|
|
380
|
+
flash(s, tx('rcat.service_deleted'));
|
|
380
381
|
return back(reply, s, `${root}/${c.id}`);
|
|
381
382
|
});
|
|
382
383
|
|
|
@@ -392,12 +393,12 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
392
393
|
const svc = await owner.one<{ id: number; catalog_id: number }>(
|
|
393
394
|
`select x.id, x.catalog_id from meta.rest_catalog_service x join meta.rest_catalog c on c.id = x.catalog_id
|
|
394
395
|
where x.id = $1 and c.workspace_id = meta.app_workspace($2)`, [/^\d{1,9}$/.test(String(req.body?.service)) ? req.body!.service : null, appId]);
|
|
395
|
-
if (!svc) throw new Error('
|
|
396
|
+
if (!svc) throw new Error(tx('rcat.choose_a_service_of_one'));
|
|
396
397
|
const c = (await loadCatalog(svc.catalog_id))!;
|
|
397
398
|
const v = sourceFromService(c, c.services.find((x) => x.id === svc.id)!, { name: req.body?.name, credential: req.body?.credential });
|
|
398
|
-
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name)) throw new Error('
|
|
399
|
+
if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name)) throw new Error(tx('rcat.the_name_is_upper_case'));
|
|
399
400
|
if (v.credential && !(await owner.one('select 1 as ok from meta.web_credential where app_id = $1 and name = $2 union all select 1 from meta.workspace_credential where workspace_id = meta.app_workspace($1) and name = $2', [appId, v.credential])))
|
|
400
|
-
throw new Error(
|
|
401
|
+
throw new Error(tx('rcat.no_credential', { name: v.credential }));
|
|
401
402
|
const problems = sourceProblems(v);
|
|
402
403
|
if (problems.length) throw new Error(problems.join(' '));
|
|
403
404
|
const r = await owner.one<{ id: number }>(
|
|
@@ -408,7 +409,7 @@ export async function restCatalogRoutes(app: FastifyInstance) {
|
|
|
408
409
|
return back(reply, s, `${BASE}/apps/${appId}/shared?c=rest_source-${r!.id}`);
|
|
409
410
|
} catch (e) {
|
|
410
411
|
const m = (e as Error).message;
|
|
411
|
-
flash(s, m.includes('rest_source_app_id_name_key') ? '
|
|
412
|
+
flash(s, m.includes('rest_source_app_id_name_key') ? tx('rcat.this_application_has_a_rest') : m, 'error');
|
|
412
413
|
return back(reply, s, retry);
|
|
413
414
|
}
|
|
414
415
|
});
|
|
@@ -430,18 +431,18 @@ export async function catalogPicker(appId: number, s: Session): Promise<Raw | ''
|
|
|
430
431
|
`select x.id, x.name, x.method, x.path, c.name as catalog from meta.rest_catalog_service x join meta.rest_catalog c on c.id = x.catalog_id
|
|
431
432
|
where c.workspace_id = meta.app_workspace($1) order by lower(c.name), x.position, x.name`, [appId])).rows;
|
|
432
433
|
if (!services.length)
|
|
433
|
-
return html`<p class="muted u-mt0 u-mb1">${icon('globe')}
|
|
434
|
+
return html`<p class="muted u-mt0 u-mb1">${icon('globe')} ${tx.rich('rcat.described_once', { link: html`<a href="${root}">${tx('rcat.rest_source_catalogs')}</a>` })}</p>`;
|
|
434
435
|
const credentials = (await owner.query('select name from meta.web_credential where app_id = $1 union select name from meta.workspace_credential where workspace_id = meta.app_workspace($1) order by name', [appId])).rows;
|
|
435
436
|
const groups = new Map<string, typeof services>();
|
|
436
437
|
for (const x of services) groups.set(x.catalog, [...(groups.get(x.catalog) ?? []), x]);
|
|
437
|
-
return region('
|
|
438
|
-
<p class="muted u-mt0"
|
|
438
|
+
return region(tx('rcat.from_a_rest_source_catalog'), html`<form method="post" action="${BASE}/apps/${appId}/rest-sources/from-catalog">${csrf(s)}
|
|
439
|
+
<p class="muted u-mt0">${tx.rich('rcat.copy_of', { link: html`<a href="${root}">${tx('rcat.catalogs_link')}</a>` })}</p>
|
|
439
440
|
<div class="form-grid">
|
|
440
|
-
<div class="field" data-wide><label class="label" for="f_service"
|
|
441
|
+
<div class="field" data-wide><label class="label" for="f_service">${tx('rcat.service')}</label><select id="f_service" name="service" required>
|
|
441
442
|
${[...groups].map(([cat, xs]) => html`<optgroup label="${cat}">${xs.map((x) => html`<option value="${x.id}">${x.name} · ${x.method} ${x.path}</option>`)}</optgroup>`)}</select></div>
|
|
442
|
-
${input('name', '
|
|
443
|
-
<div class="field"><label class="label" for="f_credential"
|
|
443
|
+
${input('name', tx('rcat.name'), '', { placeholder: tx('rcat.the_service_s_name') })}
|
|
444
|
+
<div class="field"><label class="label" for="f_credential">${tx('rcat.web_credential')}</label><select id="f_credential" name="credential"><option value="">none</option>${credentials.map((c) => html`<option>${c.name}</option>`)}</select></div>
|
|
444
445
|
</div>
|
|
445
|
-
<div class="buttons"><button class="btn btn-hot">${icon('plus')}
|
|
446
|
+
<div class="buttons"><button class="btn btn-hot">${icon('plus')} ${tx('rcat.create_from_the_catalog')}</button></div></form>`);
|
|
446
447
|
}
|
|
447
448
|
|
package/src/builder/rest.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { html } from '../html.ts';
|
|
2
2
|
import { handlerProblems, type Handler } from '../runtime/rest.ts';
|
|
3
3
|
import { publicUrl } from '../sso.ts';
|
|
4
|
+
import { scopedTx as tx } from './i18n.ts';
|
|
4
5
|
|
|
5
6
|
// Shared Components → REST modules: the endpoints, curl examples and the
|
|
6
7
|
// OpenAPI description of a module.
|
|
@@ -10,15 +11,15 @@ export function restExtras(a: { id: number; alias: string }, row: { name: string
|
|
|
10
11
|
const handlers = Array.isArray(row.handlers) ? row.handlers : [];
|
|
11
12
|
const problems = handlerProblems(handlers);
|
|
12
13
|
const example = handlers.find((h) => h.method === 'GET') ?? handlers[0];
|
|
13
|
-
return html`<fieldset class="prop-group u-mt125"><legend
|
|
14
|
+
return html`<fieldset class="prop-group u-mt125"><legend>${tx('rst.endpoints')}</legend>
|
|
14
15
|
${problems.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${problems.map((p) => html`<li>${p}</li>`)}</ul></div>` : ''}
|
|
15
|
-
${!row.enabled ? html`<p class="muted"
|
|
16
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
17
|
-
<tbody>${handlers.map((h) => html`<tr><td data-label="
|
|
18
|
-
<td data-label="
|
|
19
|
-
${example ? html`<p class="muted small"
|
|
16
|
+
${!row.enabled ? html`<p class="muted">${tx('rst.disabled')}</p>` : ''}
|
|
17
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('rst.method')}</th><th>${tx('rst.url')}</th><th>${tx('rst.type')}</th><th>${tx('rst.access')}</th></tr></thead>
|
|
18
|
+
<tbody>${handlers.map((h) => html`<tr><td data-label="${tx('rst.method')}"><code>${h.method}</code></td><td data-label="${tx('rst.url')}"><code>${base}/${h.path}</code></td>
|
|
19
|
+
<td data-label="${tx('rst.type')}">${h.type}</td><td data-label="${tx('rst.access')}">${h.auth === 'public' ? 'public' : h.roles?.length ? `token, role ${h.roles.join(' or ')}` : 'token'}</td></tr>`)}</tbody></table></div>
|
|
20
|
+
${example ? html`<p class="muted small">${tx.rich('rst.try_it', { link: html`<a href="/builder/apps/${a.id}/api">${tx('rst.rest_api')}</a>` })}</p>
|
|
20
21
|
<pre class="source">curl ${base}/${example.path.replace(/:([a-z_]+)/gi, '1')} -H "Authorization: Bearer $TOKEN"</pre>` : ''}
|
|
21
|
-
<p class="muted small"
|
|
22
|
-
<p class="wrap-anywhere"
|
|
22
|
+
<p class="muted small">${tx.rich('rst.edit_in', { link: html`<a href="/builder/sql/rest/modules">${tx('rst.modules_path')}</a>` })}</p>
|
|
23
|
+
<p class="wrap-anywhere">${tx('rst.openapi')} <a href="${base}/openapi.json" target="_blank" rel="noopener"><code>${base}/openapi.json</code></a></p>
|
|
23
24
|
</fieldset>`;
|
|
24
25
|
}
|
package/src/builder/routes.ts
CHANGED
|
@@ -23,13 +23,14 @@ import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './works
|
|
|
23
23
|
import { securityRoutes } from './security.ts';
|
|
24
24
|
import { instanceRoutes } from './instance.ts';
|
|
25
25
|
import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
|
|
26
|
-
import {
|
|
26
|
+
import { notificationSettings, saveNotificationSettings } from './notification-settings.ts';
|
|
27
|
+
import { kindLabel, WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
27
28
|
import { createPageRoutes } from './create-page.ts';
|
|
28
29
|
import { featureRoutes } from './features.ts';
|
|
29
30
|
import { appFromFileRoutes } from './appfromfile.ts';
|
|
30
31
|
import { clearDevbar } from '../runtime/devbar.ts';
|
|
31
32
|
import { EMAIL } from './find-workspace.ts';
|
|
32
|
-
import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
|
|
33
|
+
import { scopedTx as tx, cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
|
|
33
34
|
import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeyByCredential, passkeySignin, passkeysOf, trustBrowser, trustDays, usePasskey, userHandle } from '../mfa.ts';
|
|
34
35
|
import { isIP } from 'node:net';
|
|
35
36
|
import { giveChallenge, takeChallenge } from './security.ts';
|
|
@@ -587,7 +588,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
587
588
|
if (!s) return;
|
|
588
589
|
try {
|
|
589
590
|
const ws = currentWorkspace(s);
|
|
590
|
-
if (ws < 1) throw new Error('
|
|
591
|
+
if (ws < 1) throw new Error(tx('rte.you_are_not_a_developer'));
|
|
591
592
|
const doc = JSON.parse(req.body?.doc ?? '');
|
|
592
593
|
const r = await owner.tx(async (c) => {
|
|
593
594
|
const made = (await c.query('select meta.import_app($1::jsonb, $2) as id', [JSON.stringify(doc), req.body?.alias?.trim() || null])).rows[0];
|
|
@@ -686,7 +687,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
686
687
|
<p class="muted u-mt0">${tx('app.from_table_text')}</p>
|
|
687
688
|
<form method="get" action="${BASE}/apps/${a.id}/wizard">
|
|
688
689
|
<div class="form-grid">
|
|
689
|
-
${select('kind', tx('app.page_type'), 'report_form', WIZARD_KINDS.map(([k, label]): [string, string] => [k, label]))}
|
|
690
|
+
${select('kind', tx('app.page_type'), 'report_form', WIZARD_KINDS.map(([k, label]): [string, string] => [k, kindLabel(k, label)]))}
|
|
690
691
|
${select('table', tx('app.table_or_view'), '', tables.map((t): [string, string] => [t.t, t.access ? t.t : tx('app.no_access', { table: t.t, role: a.db_role })]))}
|
|
691
692
|
</div>
|
|
692
693
|
<div class="buttons"><button class="btn btn-hot">${tx('common.next')}</button></div>
|
|
@@ -722,7 +723,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
722
723
|
<li><a href="${BASE}/apps/${a.id}/settings?tab=definition">${icon('trash')} ${tx('app.task.delete')}</a></li>
|
|
723
724
|
</ul></aside>
|
|
724
725
|
</div>`;
|
|
725
|
-
return send(reply, s, shell(s, a.name, [['
|
|
726
|
+
return send(reply, s, shell(s, a.name, [[tx('rte.app_builder'), BASE], [a.name]], main));
|
|
726
727
|
});
|
|
727
728
|
|
|
728
729
|
app.post(`${BASE}/apps/:id/pages`, async (req: Req, reply) => {
|
|
@@ -775,8 +776,8 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
775
776
|
// ---------------------------------------------------------------- settings
|
|
776
777
|
/** (sprint 74) The tabs of an application's Settings. */
|
|
777
778
|
const SETTINGS_TABS: [string, string][] = [
|
|
778
|
-
['definition', '
|
|
779
|
-
['globalization', '
|
|
779
|
+
['definition', tx('rte.definition')], ['security', tx('rte.security')], ['signin', tx('rte.sign_in')], ['ui', tx('rte.user_interface')],
|
|
780
|
+
['globalization', tx('rte.globalization')], ['errors', tx('rte.errors_and_sessions')], ['pwa', tx('rte.progressive_web_app')],
|
|
780
781
|
];
|
|
781
782
|
app.get(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
|
|
782
783
|
const s = await developer(req, reply);
|
|
@@ -881,6 +882,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
881
882
|
<small class="help">${tx('st.user_choice_help')}</small></div>
|
|
882
883
|
<p><a class="btn" href="${BASE}/apps/${a.id}/theme">${icon('settings')} ${tx('st.roller')}</a>
|
|
883
884
|
<span class="muted">${(() => { const n = appStyles(a.theme).length; return n ? tx(n === 1 ? 'st.styles_one' : 'st.styles', { n }) : tx('st.no_styles'); })()}</span></p>
|
|
885
|
+
${notificationSettings(a, tx)}
|
|
884
886
|
`)}${end}
|
|
885
887
|
${panel('globalization')}${region(tx('st.tab.globalization'), html`
|
|
886
888
|
<div class="form-grid">
|
|
@@ -908,17 +910,17 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
908
910
|
${panel('security')}${region(tx('st.checklist'), html`<ul class="checklist">
|
|
909
911
|
<li>${a.db_role ? '✓' : '✗'} ${tx('st.ck.role')} ${a.db_role ? html`(<code>${a.db_role}</code>)` : html`<b>${tx('st.ck.runtime')}</b>`}</li>
|
|
910
912
|
<li>${a.authentication !== 'none' ? '✓' : '•'} ${a.authentication !== 'none' ? tx('st.ck.signin') : tx('st.ck.public')}</li>
|
|
911
|
-
${a.authentication === 'header' ? html`<li>${headerProxiesConfigured() ? '✓' : '✗'} Sign-in: HTTP header <code>${a.header_name || DEFAULT_HEADER}</code> ${headerProxiesConfigured() ? 'from the proxies in PGKILN_AUTH_HEADER_PROXIES' : html`<b
|
|
912
|
-
${a.authentication === 'database' ? html`<li>${a.db_auth_roles?.length || a.db_auth_member_of ? '✓' : '✗'} Sign-in: database accounts (${[a.db_auth_roles?.length ? `roles ${a.db_auth_roles.join(', ')}` : '', a.db_auth_member_of ? `members of ${a.db_auth_member_of}` : ''].filter(Boolean).join('; ') || html`<b
|
|
913
|
-
${a.authentication === 'custom' ? html`<li>${a.custom_auth_function || a.custom_auth_code ? '✓' : '✗'} Sign-in: custom ${a.custom_auth_function ? html`function <code>${a.custom_auth_function}</code>` : a.custom_auth_code ? 'function body' : html`<b
|
|
914
|
-
${a.authentication === 'app_users' ? html`<li>Sign-in: ${[a.local_login ? 'password' : '', ...a.ldap_directories.map((d: string) => `LDAP ${d}`), ...a.sso_providers].filter(Boolean).join(', ') || html`<b
|
|
913
|
+
${a.authentication === 'header' ? html`<li>${headerProxiesConfigured() ? '✓' : '✗'} Sign-in: HTTP header <code>${a.header_name || DEFAULT_HEADER}</code> ${headerProxiesConfigured() ? 'from the proxies in PGKILN_AUTH_HEADER_PROXIES' : html`<b>${tx('rte.refused_pgkiln_auth_header_proxies')}</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}${a.header_auto_create ? ', new accounts created automatically' : ''}</li>` : ''}
|
|
914
|
+
${a.authentication === 'database' ? html`<li>${a.db_auth_roles?.length || a.db_auth_member_of ? '✓' : '✗'} Sign-in: database accounts (${[a.db_auth_roles?.length ? `roles ${a.db_auth_roles.join(', ')}` : '', a.db_auth_member_of ? `members of ${a.db_auth_member_of}` : ''].filter(Boolean).join('; ') || html`<b>${tx('rte.no_roles_allowed')}</b>`})</li>` : ''}
|
|
915
|
+
${a.authentication === 'custom' ? html`<li>${a.custom_auth_function || a.custom_auth_code ? '✓' : '✗'} Sign-in: custom ${a.custom_auth_function ? html`function <code>${a.custom_auth_function}</code>` : a.custom_auth_code ? 'function body' : html`<b>${tx('rte.no_check_configured_nobody_can')}</b>`}${a.custom_auth_post_code ? ', with post-authentication code' : ''}</li>` : ''}
|
|
916
|
+
${a.authentication === 'app_users' ? html`<li>Sign-in: ${[a.local_login ? 'password' : '', ...a.ldap_directories.map((d: string) => `LDAP ${d}`), ...a.sso_providers].filter(Boolean).join(', ') || html`<b>${tx('rte.no_method_enabled')}</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}</li>` : ''}
|
|
915
917
|
<li>${a.debug ? `✗ ${tx('st.ck.debug_on')}` : `✓ ${tx('st.ck.debug_off')}`}</li>
|
|
916
918
|
<li>${a.debug_level ? `✗ ${tx('st.ck.msgs_on', { level: a.debug_level })}` : `✓ ${tx('st.ck.msgs_off')}`}</li>
|
|
917
919
|
<li>${tx('st.ck.unprotected', { n: (await owner.one("select count(*)::int as n from meta.page where app_id = $1 and protection = 'unrestricted'", [a.id])).n })}</li>
|
|
918
920
|
<li>${tx('st.ck.public_pages', { n: (await owner.one('select count(*)::int as n from meta.page where app_id = $1 and not requires_auth', [a.id])).n })}</li>
|
|
919
921
|
</ul>`)}${end}
|
|
920
922
|
</div>`;
|
|
921
|
-
return send(reply, s, shell(s, tx('st.title', { app: a.name }), [['
|
|
923
|
+
return send(reply, s, shell(s, tx('st.title', { app: a.name }), [[tx('rte.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('rte.settings')]], main));
|
|
922
924
|
});
|
|
923
925
|
|
|
924
926
|
// (085) error handling function and session timeout warning
|
|
@@ -992,6 +994,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
992
994
|
(b.version ?? '').trim().slice(0, 40) || null],
|
|
993
995
|
);
|
|
994
996
|
await saveTimeZoneSettings(req.params.id, b);
|
|
997
|
+
await saveNotificationSettings(req.params.id, b, tr(s));
|
|
995
998
|
await saveLoginSettings(req.params.id, b);
|
|
996
999
|
flash(s, tr(s)('st.settings_saved'));
|
|
997
1000
|
} catch (e) {
|
|
@@ -1041,25 +1044,25 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
1041
1044
|
]);
|
|
1042
1045
|
const main = html`${appHeader(a, 'activity', s)}
|
|
1043
1046
|
<div class="stat-grid">
|
|
1044
|
-
<div class="stat"><b>${stats.views}</b><span
|
|
1045
|
-
<div class="stat"><b>${stats.users}</b><span
|
|
1046
|
-
<div class="stat"><b>${stats.avg_ms} ms</b><span
|
|
1047
|
-
<div class="stat"><b>${stats.failures}</b><span
|
|
1048
|
-
<div class="stat"><b>${stats.problems}</b><span
|
|
1047
|
+
<div class="stat"><b>${stats.views}</b><span>${tx('rte.page_views_24h')}</span></div>
|
|
1048
|
+
<div class="stat"><b>${stats.users}</b><span>${tx('rte.distinct_users_24h')}</span></div>
|
|
1049
|
+
<div class="stat"><b>${stats.avg_ms} ms</b><span>${tx('rte.average_page_time')}</span></div>
|
|
1050
|
+
<div class="stat"><b>${stats.failures}</b><span>${tx('rte.failed_locked_sign_ins_24h')}</span></div>
|
|
1051
|
+
<div class="stat"><b>${stats.problems}</b><span>${tx('rte.errors_access_denied_24h')}</span></div>
|
|
1049
1052
|
</div>
|
|
1050
1053
|
<div class="columns">
|
|
1051
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/top-sql">${icon('database')}
|
|
1052
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/ai">${icon('bolt')}
|
|
1053
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/feedback">${icon('chat')}
|
|
1054
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/mail">${icon('send')}
|
|
1055
|
-
<p><a class="btn" href="${BASE}/apps/${a.id}/debug">${icon('list')}
|
|
1056
|
-
${region('
|
|
1054
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/top-sql">${icon('database')} ${tx('rte.top_sql')}</a> <span class="muted">${tx('rte.the_slowest_statements_of_this')}</span></p>
|
|
1055
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/ai">${icon('bolt')} ${tx('rte.ai_usage')}</a> <span class="muted">${tx('rte.the_ai_services_this_application')}</span></p>
|
|
1056
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/feedback">${icon('chat')} ${tx('rte.feedback')}</a> <span class="muted">${tx('rte.what_users_sent_from_the')}</span></p>
|
|
1057
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/mail">${icon('send')} ${tx('rte.mail')}</a> <span class="muted">${tx('rte.the_mail_this_application_queued')}</span></p>
|
|
1058
|
+
<p><a class="btn" href="${BASE}/apps/${a.id}/debug">${icon('list')} ${tx('rte.debug_messages')}</a> <span class="muted">${a.debug_level ? `on (level ${a.debug_level}): ` : 'off: '}timed steps of each request and messages from <code>meta.debug()</code></span></p>
|
|
1059
|
+
${region(tx('rte.page_views_by_page_7'), html`<div class="table-wrap"><table class="report"><thead><tr><th class="num">${tx('rte.page')}</th><th>${tx('rte.name')}</th><th class="num">${tx('rte.views')}</th><th class="num">${tx('rte.avg_ms')}</th><th class="num">${tx('rte.max_ms')}</th></tr></thead>
|
|
1057
1060
|
<tbody>${byPage.rows.map((r) => html`<tr><td class="num">${r.page_no}</td><td>${r.name}</td><td class="num">${r.views}</td><td class="num">${r.avg_ms}</td><td class="num">${r.max_ms}</td></tr>`)}</tbody></table></div>`)}
|
|
1058
|
-
${region('
|
|
1059
|
-
<div class="table-wrap"><table class="report"><thead><tr><th
|
|
1061
|
+
${region(tx('rte.recent_events'), html`<p class="muted u-mt0">${req.query.all === '1' ? html`Showing all events. <a href="?">${tx('rte.hide_page_views')}</a>` : html`Sign-ins, denials and errors. <a href="?all=1">${tx('rte.include_page_views')}</a>`}</p>
|
|
1062
|
+
<div class="table-wrap"><table class="report"><thead><tr><th>${tx('rte.when')}</th><th>${tx('rte.event')}</th><th>${tx('rte.user')}</th><th class="num">${tx('rte.page')}</th><th>IP</th><th>${tx('rte.detail')}</th></tr></thead>
|
|
1060
1063
|
<tbody>${events.rows.map((r) => html`<tr><td>${String(r.at).slice(0, 19)}</td><td><span class="ev ev-${r.event}">${r.event}</span></td><td>${r.username}</td><td class="num">${r.page_no}</td><td>${r.ip}</td><td title="${r.detail}">${r.detail}</td></tr>`)}</tbody></table></div>`)}
|
|
1061
1064
|
</div>`;
|
|
1062
|
-
return send(reply, s, shell(s, '
|
|
1065
|
+
return send(reply, s, shell(s, tx('rte.activity'), [[tx('rte.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('rte.activity')]], main));
|
|
1063
1066
|
});
|
|
1064
1067
|
|
|
1065
1068
|
// ---------------------------------------------------------------- developers
|
|
@@ -1108,7 +1111,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
1108
1111
|
</form>`
|
|
1109
1112
|
: html`<p class="muted">${tx('dev.admins_only')}</p>`}`)}
|
|
1110
1113
|
</div>`;
|
|
1111
|
-
return send(reply, s, shell(s, tx('dev.title'), [['
|
|
1114
|
+
return send(reply, s, shell(s, tx('dev.title'), [[tx('rte.developers')]], main, 'developers'));
|
|
1112
1115
|
});
|
|
1113
1116
|
|
|
1114
1117
|
app.post(`${BASE}/developers`, async (req: Req, reply) => {
|