pgkiln 0.82.0 → 0.84.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/153_component_events.sql +21 -0
- package/db/migrations/154_notifications.sql +186 -0
- package/docs/apex-feature-parity.md +4 -3
- package/docs/guide/03-builder.md +10 -1
- package/docs/guide/04-pages-and-regions.md +44 -1
- package/docs/guide/06-processing.md +22 -1
- package/docs/guide/07-dynamic-actions.md +28 -0
- package/docs/guide/09-reference.md +15 -0
- package/docs/guide/11-from-apex.md +3 -1
- package/docs/guide/12-development.md +16 -1
- package/package.json +1 -1
- package/public/app.css +31 -0
- package/public/app.js +163 -4
- package/public/builder.css +25 -2
- package/public/data-modeler.js +8 -5
- package/public/sql-monitor.js +5 -2
- package/public/sw.js +5 -1
- package/src/app.ts +2 -0
- package/src/builder/ai-builder.ts +50 -46
- package/src/builder/api.ts +49 -49
- package/src/builder/autorest.ts +92 -87
- package/src/builder/changelog.ts +39 -37
- package/src/builder/code-editor.ts +1 -1
- package/src/builder/components.ts +13 -8
- package/src/builder/data-modeler.ts +89 -84
- package/src/builder/data-security.ts +39 -37
- package/src/builder/dataload.ts +69 -65
- package/src/builder/datapump.ts +108 -97
- package/src/builder/ddl-tools.ts +38 -34
- package/src/builder/document-view.ts +11 -11
- package/src/builder/i18n/de.ts +1635 -0
- package/src/builder/i18n/en.ts +1635 -0
- package/src/builder/i18n/es.ts +1635 -0
- package/src/builder/i18n/fr.ts +1635 -0
- package/src/builder/i18n/it.ts +1635 -0
- package/src/builder/i18n/ja.ts +1635 -0
- package/src/builder/i18n/ko.ts +1635 -0
- package/src/builder/i18n/nl.ts +1635 -0
- package/src/builder/i18n/pt.ts +1635 -0
- package/src/builder/i18n/zh-cn.ts +1635 -0
- package/src/builder/i18n/zh-tw.ts +1635 -0
- package/src/builder/i18n.ts +18 -0
- package/src/builder/notification-settings.ts +27 -0
- package/src/builder/object-actions.ts +18 -16
- package/src/builder/object-browser.ts +2 -2
- package/src/builder/querybuilder.ts +42 -36
- package/src/builder/quicksql.ts +16 -18
- package/src/builder/region-settings.ts +201 -199
- package/src/builder/report-settings.ts +64 -60
- package/src/builder/rest-modules.ts +145 -143
- package/src/builder/routes.ts +3 -0
- package/src/builder/sampledata.ts +59 -52
- package/src/builder/scheduling.ts +180 -159
- package/src/builder/scripts.ts +71 -65
- package/src/builder/sql-charts.ts +36 -30
- package/src/builder/sql-monitor.ts +95 -89
- package/src/builder/sql-utilities.ts +47 -40
- package/src/builder/top-sql.ts +21 -17
- package/src/builder/unload.ts +48 -44
- package/src/builder/worksheet.ts +3 -3
- package/src/cli/replace.ts +2 -0
- package/src/cron-schedule.ts +47 -17
- 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 +11 -3
- package/src/runtime/calendar.ts +3 -2
- package/src/runtime/context.ts +2 -0
- package/src/runtime/engine.ts +2 -1
- package/src/runtime/feedback.ts +1 -1
- package/src/runtime/grid.ts +1 -0
- package/src/runtime/lists.ts +43 -11
- package/src/runtime/maps.ts +7 -4
- 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 +18 -4
- package/src/runtime/routes.ts +16 -4
- package/src/runtime/signin.ts +2 -1
- package/src/runtime/tree.ts +4 -3
- package/src/session.ts +13 -4
|
@@ -11,6 +11,7 @@ import { isAdmin } from './locks.ts';
|
|
|
11
11
|
import { appHeader, back, BASE, csrf, developer, flash, region, select, send, shell, workshopTabs, type Req } from './ui.ts';
|
|
12
12
|
import { workshopDb } from './workspaces.ts';
|
|
13
13
|
import { WIZARD_KINDS, wizardTables } from './wizards.ts';
|
|
14
|
+
import { tr } from './i18n.ts';
|
|
14
15
|
|
|
15
16
|
// App Builder AI (migration 062), with the AI service an administrator
|
|
16
17
|
// chooses for the builder (meta.builder_ai):
|
|
@@ -114,16 +115,17 @@ const opt = (value: string, label: string, current: unknown) => html`<option val
|
|
|
114
115
|
async function serviceBox(s: Session) {
|
|
115
116
|
const svc = await builderService();
|
|
116
117
|
const admin = await isAdmin(s.username);
|
|
118
|
+
const tx = tr(s);
|
|
117
119
|
if (!admin)
|
|
118
120
|
return svc
|
|
119
|
-
? html`<p class="muted"
|
|
120
|
-
: html`<div class="alert alert-info"
|
|
121
|
+
? html`<p class="muted">${tx.rich('swai.service', { name: html`<b>${svc.name}</b>`, provider: providerLabel(svc.provider), model: svc.model })}</p>`
|
|
122
|
+
: html`<div class="alert alert-info">${tx('swai.no_service')}</div>`;
|
|
121
123
|
const all = (await owner.query<{ name: string; provider: string; model: string }>('select name, provider, model from meta.ai_service where enabled order by name')).rows;
|
|
122
124
|
return html`<form method="post" action="${BASE}/sql/ai/service" class="component-form">${csrf(s)}
|
|
123
|
-
<div class="form-grid"><div class="field"><label class="label" for="f_ai_builder_service"
|
|
124
|
-
<select id="f_ai_builder_service" name="service">${opt('', '
|
|
125
|
-
<small class="help"
|
|
126
|
-
<div class="buttons"><button class="btn"
|
|
125
|
+
<div class="form-grid"><div class="field"><label class="label" for="f_ai_builder_service">${tx('swai.service_label')}</label>
|
|
126
|
+
<select id="f_ai_builder_service" name="service">${opt('', tx('swai.none'), svc?.name ?? '')}${all.map((x) => opt(x.name, `${x.name} (${providerLabel(x.provider)}, ${x.model})`, svc?.name ?? ''))}</select>
|
|
127
|
+
<small class="help">${tx('swai.service_help')}</small></div></div>
|
|
128
|
+
<div class="buttons"><button class="btn">${tx('scr.save')}</button></div></form>`;
|
|
127
129
|
}
|
|
128
130
|
|
|
129
131
|
const errorBox = (e: unknown) => html`<div class="alert alert-error" role="alert">${e instanceof AiError || e instanceof Error ? e.message : 'The AI request failed.'}</div>`;
|
|
@@ -142,30 +144,31 @@ const SQL_SCHEMA = {
|
|
|
142
144
|
|
|
143
145
|
async function workshopPage(s: Session, reply: FastifyReply, out: { question?: string; schema?: string; sql?: string; error?: string; result?: Raw }) {
|
|
144
146
|
const list = await schemas(s);
|
|
145
|
-
const
|
|
147
|
+
const tx = tr(s);
|
|
148
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('ai', s)}
|
|
146
149
|
${await serviceBox(s)}
|
|
147
|
-
<p><a href="${BASE}/sql/ai/describe"
|
|
150
|
+
<p><a href="${BASE}/sql/ai/describe">${tx('swai.describe_link')} ▸</a> <span class="muted">${tx('swai.describe_why')}</span></p>
|
|
148
151
|
${out.result ?? ''}
|
|
149
152
|
<div class="columns">
|
|
150
|
-
${region('
|
|
151
|
-
<p class="muted u-mt0"
|
|
153
|
+
${region(tx('swai.question_title'), html`
|
|
154
|
+
<p class="muted u-mt0">${tx('swai.question_intro')}</p>
|
|
152
155
|
<form method="post" action="${BASE}/sql/ai/sql">${csrf(s)}
|
|
153
|
-
<div class="form-grid">${select('schema', '
|
|
154
|
-
<div class="field"><label class="label" for="f_question"
|
|
155
|
-
<textarea id="f_question" name="question" rows="4" maxlength="4000" required placeholder="
|
|
156
|
-
<div class="buttons"><button class="btn btn-hot" data-busy="
|
|
156
|
+
<div class="form-grid">${select('schema', tx('sm.schema'), out.schema ?? list[0] ?? '', list)}</div>
|
|
157
|
+
<div class="field"><label class="label" for="f_question">${tx('swai.question')}</label>
|
|
158
|
+
<textarea id="f_question" name="question" rows="4" maxlength="4000" required placeholder="${tx('swai.question_placeholder')}">${out.question ?? ''}</textarea></div>
|
|
159
|
+
<div class="buttons"><button class="btn btn-hot" data-busy="${tx('swai.thinking')}">${tx('swai.write_sql')}</button></div>
|
|
157
160
|
</form>`)}
|
|
158
|
-
${region('
|
|
161
|
+
${region(tx('swai.explain_title'), html`
|
|
159
162
|
<form method="post" action="${BASE}/sql/ai/explain">${csrf(s)}
|
|
160
|
-
<div class="form-grid">${select('schema', '
|
|
163
|
+
<div class="form-grid">${select('schema', tx('swai.schema_optional'), out.schema ?? '', [['', tx('swai.none_short')], ...list.map((x): [string, string] => [x, x])])}</div>
|
|
161
164
|
<div class="field"><label class="label" for="f_sql">SQL</label>
|
|
162
165
|
<textarea id="f_sql" name="sql" class="code" rows="6" maxlength="20000" spellcheck="false">${out.sql ?? ''}</textarea></div>
|
|
163
|
-
<div class="field"><label class="label" for="f_error"
|
|
166
|
+
<div class="field"><label class="label" for="f_error">${tx('swai.error_optional')}</label>
|
|
164
167
|
<textarea id="f_error" name="error" rows="2" maxlength="4000">${out.error ?? ''}</textarea></div>
|
|
165
|
-
<div class="buttons"><button class="btn btn-hot" data-busy="
|
|
168
|
+
<div class="buttons"><button class="btn btn-hot" data-busy="${tx('swai.thinking')}">${tx('sm.explain')}</button></div>
|
|
166
169
|
</form>`)}
|
|
167
170
|
</div>`;
|
|
168
|
-
return send(reply, s, shell(s, '
|
|
171
|
+
return send(reply, s, shell(s, tx('rail.sql_workshop'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.ai')]], main, 'sql'));
|
|
169
172
|
}
|
|
170
173
|
|
|
171
174
|
// ---------------------------------------------------------------- describe tables
|
|
@@ -178,30 +181,31 @@ async function describePage(s: Session, reply: FastifyReply, schema: string, tab
|
|
|
178
181
|
const t = table ? tables.find((x) => x.table === table) : undefined;
|
|
179
182
|
const noted = new Set((await owner.query<{ t: string }>(`select distinct table_name as t from meta.ai_table_note where schema_name = $1`, [schema])).rows.map((r) => r.t));
|
|
180
183
|
const value = (col: string, current: string | null) => (draft ? (col === '' ? draft.table : draft.columns?.[col]) ?? current ?? '' : current ?? '');
|
|
181
|
-
const
|
|
182
|
-
|
|
184
|
+
const tx = tr(s);
|
|
185
|
+
const main = html`<h1 class="u-mb1">${tx('rail.sql_workshop')}</h1>${workshopTabs('ai', s)}
|
|
186
|
+
<p><a href="${BASE}/sql/ai">◂ ${tx('swai.back')}</a></p>
|
|
183
187
|
${message ?? ''}
|
|
184
|
-
${region('
|
|
185
|
-
<p class="muted u-mt0"
|
|
188
|
+
${region(tx('swai.describe_link'), html`
|
|
189
|
+
<p class="muted u-mt0">${tx('swai.describe_intro')}</p>
|
|
186
190
|
<form method="get" action="${BASE}/sql/ai/describe"><div class="form-grid">
|
|
187
|
-
${select('schema', '
|
|
188
|
-
</div><div class="buttons"><button class="btn"
|
|
189
|
-
${tables.length ? html`<ul class="ai-tables">${tables.map((x) => html`<li><a href="${BASE}/sql/ai/describe?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(x.table)}"${x.table === table ? raw(' aria-current="true"') : ''}>${x.table}</a> <span class="muted">${x.kind}${noted.has(x.table) ? '
|
|
191
|
+
${select('schema', tx('sm.schema'), schema, [['', tx('swai.choose')], ...list.map((x): [string, string] => [x, x])])}
|
|
192
|
+
</div><div class="buttons"><button class="btn">${tx('swai.show_tables')}</button></div></form>
|
|
193
|
+
${tables.length ? html`<ul class="ai-tables">${tables.map((x) => html`<li><a href="${BASE}/sql/ai/describe?schema=${encodeURIComponent(schema)}&table=${encodeURIComponent(x.table)}"${x.table === table ? raw(' aria-current="true"') : ''}>${x.table}</a> <span class="muted">${x.kind}${noted.has(x.table) ? `, ${tx('swai.described')}` : ''}</span></li>`)}</ul>` : ''}`)}
|
|
190
194
|
${t ? region(`${t.schema}.${t.table}`, html`
|
|
191
|
-
${draft ? html`<div class="alert alert-info"
|
|
195
|
+
${draft ? html`<div class="alert alert-info">${tx('swai.drafted')}</div>` : ''}
|
|
192
196
|
<form method="post" action="${BASE}/sql/ai/describe">${csrf(s)}
|
|
193
197
|
<input type="hidden" name="schema" value="${t.schema}"><input type="hidden" name="table" value="${t.table}">
|
|
194
|
-
<div class="field"><label class="label" for="f_note_"
|
|
198
|
+
<div class="field"><label class="label" for="f_note_">${tx('swai.the_kind', { kind: t.kind })}</label>
|
|
195
199
|
<textarea id="f_note_" name="note:" rows="2" maxlength="2000">${value('', t.note)}</textarea></div>
|
|
196
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col"
|
|
197
|
-
${t.columns.map((c) => html`<tr><td data-label="
|
|
198
|
-
<td data-label="
|
|
200
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th scope="col">${tx('dm.col.column')}</th><th scope="col">${tx('scr.description')}</th></tr></thead><tbody>
|
|
201
|
+
${t.columns.map((c) => html`<tr><td data-label="${tx('dm.col.column')}"><code>${c.name}</code> <span class="muted">${c.type}${c.pk ? `, ${tx('swai.key')}` : ''}${c.fk ? `, → ${c.fk}` : ''}</span></td>
|
|
202
|
+
<td data-label="${tx('scr.description')}"><input name="note:${c.name}" maxlength="2000" value="${value(c.name, c.note)}" aria-label="${tx('swai.description_of', { name: c.name })}"></td></tr>`)}
|
|
199
203
|
</tbody></table></div>
|
|
200
|
-
<div class="field"><label class="check"><input type="checkbox" name="comments" value="true">
|
|
201
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
202
|
-
<button class="btn" formaction="${BASE}/sql/ai/describe/draft" data-busy="
|
|
204
|
+
<div class="field"><label class="check"><input type="checkbox" name="comments" value="true"> ${tx('swai.as_comments')}</label></div>
|
|
205
|
+
<div class="buttons"><button class="btn btn-hot">${tx('swai.save_descriptions')}</button>
|
|
206
|
+
<button class="btn" formaction="${BASE}/sql/ai/describe/draft" data-busy="${tx('swai.thinking')}">${tx('swai.draft')}</button></div>
|
|
203
207
|
</form>`) : ''}`;
|
|
204
|
-
return send(reply, s, shell(s, '
|
|
208
|
+
return send(reply, s, shell(s, tx('rail.sql_workshop'), [[tx('rail.sql_workshop'), `${BASE}/sql`], [tx('sql.tool.ai'), `${BASE}/sql/ai`], [tx('swai.describe_crumb')]], main, 'sql'));
|
|
205
209
|
}
|
|
206
210
|
|
|
207
211
|
// ---------------------------------------------------------------- pages from a description
|
|
@@ -293,11 +297,11 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
293
297
|
const name = String(req.body?.service ?? '').toUpperCase();
|
|
294
298
|
const svc = name ? await owner.one<{ id: number }>('select id from meta.ai_service where name = $1', [name]) : null;
|
|
295
299
|
if (name && !svc) {
|
|
296
|
-
flash(s,
|
|
300
|
+
flash(s, tr(s)('swai.err.no_such', { name }), 'error');
|
|
297
301
|
return back(reply, s, `${BASE}/sql/ai`);
|
|
298
302
|
}
|
|
299
303
|
await owner.query('update meta.builder_ai set service_id = $1, updated_by = $2, updated_at = now()', [svc?.id ?? null, s.username]);
|
|
300
|
-
flash(s, svc ?
|
|
304
|
+
flash(s, svc ? tr(s)('swai.uses', { name }) : tr(s)('swai.uses_none'));
|
|
301
305
|
return back(reply, s, `${BASE}/sql/ai`);
|
|
302
306
|
});
|
|
303
307
|
|
|
@@ -309,8 +313,8 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
309
313
|
const schema = String(b.schema ?? '');
|
|
310
314
|
let result: Raw;
|
|
311
315
|
try {
|
|
312
|
-
if (!question) throw new AiError('config', '
|
|
313
|
-
if (!(await schemas(s)).includes(schema)) throw new AiError('config', '
|
|
316
|
+
if (!question) throw new AiError('config', tr(s)('swai.err.question'));
|
|
317
|
+
if (!(await schemas(s)).includes(schema)) throw new AiError('config', tr(s)('swai.err.schema'));
|
|
314
318
|
const res = await ask(s, {
|
|
315
319
|
system: `You write PostgreSQL ${(await owner.one<{ v: number }>(`select current_setting('server_version_num')::int / 10000 as v`))!.v} SQL for a developer, for these tables and views:\n\n${describeTables(await tableInfo({ schema }))}\n\n` +
|
|
316
320
|
'Use only those tables and columns, qualified with their schema. Write a SELECT unless the question clearly asks to change data. The developer reviews the SQL before anything runs.',
|
|
@@ -319,12 +323,12 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
319
323
|
});
|
|
320
324
|
const a = res.json as { sql?: unknown; explanation?: unknown };
|
|
321
325
|
const sql = typeof a.sql === 'string' ? a.sql.trim().replace(/;+\s*$/, '') : '';
|
|
322
|
-
result = region('
|
|
326
|
+
result = region(tr(s)('swai.proposed'), html`
|
|
323
327
|
<p>${typeof a.explanation === 'string' ? a.explanation : ''}</p>
|
|
324
328
|
<form method="post" action="${BASE}/sql">${csrf(s)}
|
|
325
|
-
<div class="field"><label class="label" for="f_ai_sql"
|
|
329
|
+
<div class="field"><label class="label" for="f_ai_sql">${tr(s)('swai.not_run')}</label>
|
|
326
330
|
<textarea id="f_ai_sql" name="sql" class="code sql-editor" rows="10" spellcheck="false" data-code="plpgsql">${sql}</textarea></div>
|
|
327
|
-
<div class="buttons"><button class="btn"
|
|
331
|
+
<div class="buttons"><button class="btn">${tr(s)('qb.run')}</button></div>
|
|
328
332
|
</form>`);
|
|
329
333
|
} catch (e) {
|
|
330
334
|
result = errorBox(e);
|
|
@@ -341,14 +345,14 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
341
345
|
const schema = String(b.schema ?? '');
|
|
342
346
|
let result: Raw;
|
|
343
347
|
try {
|
|
344
|
-
if (!sql.trim() && !error.trim()) throw new AiError('config', '
|
|
348
|
+
if (!sql.trim() && !error.trim()) throw new AiError('config', tr(s)('swai.err.paste'));
|
|
345
349
|
const tables = schema && (await schemas(s)).includes(schema) ? describeTables(await tableInfo({ schema })) : '';
|
|
346
350
|
const res = await ask(s, {
|
|
347
351
|
system: 'You explain PostgreSQL to a developer: what a query does, why an error happens and how to fix it. Be concise; show corrected SQL when it helps.' +
|
|
348
352
|
(tables ? `\n\nThe tables and views of the schema:\n\n${tables}` : ''),
|
|
349
353
|
prompt: [sql.trim() ? `Query:\n${sql.trim()}` : '', error.trim() ? `Error:\n${error.trim()}` : ''].filter(Boolean).join('\n\n'),
|
|
350
354
|
});
|
|
351
|
-
result = region('
|
|
355
|
+
result = region(tr(s)('swai.explanation'), html`<div class="ai-answer">${formatAnswer(res.text)}</div>`);
|
|
352
356
|
} catch (e) {
|
|
353
357
|
result = errorBox(e);
|
|
354
358
|
}
|
|
@@ -392,9 +396,9 @@ export async function aiBuilderRoutes(app: FastifyInstance) {
|
|
|
392
396
|
}
|
|
393
397
|
if (b.comments === 'true' && db === owner) await comment(c, t, notes);
|
|
394
398
|
});
|
|
395
|
-
flash(s,
|
|
399
|
+
flash(s, tr(s)(b.comments === 'true' ? 'swai.saved_comments' : 'swai.saved', { name: `${t.schema}.${t.table}` }));
|
|
396
400
|
} catch (e) {
|
|
397
|
-
flash(s,
|
|
401
|
+
flash(s, tr(s)('swai.not_saved', { error: (e as Error).message }), 'error');
|
|
398
402
|
}
|
|
399
403
|
return back(reply, s, `${BASE}/sql/ai/describe?schema=${encodeURIComponent(t.schema)}&table=${encodeURIComponent(t.table)}`);
|
|
400
404
|
});
|
package/src/builder/api.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { owner } from '../db.ts';
|
|
|
4
4
|
import { html, raw, type Raw } from '../html.ts';
|
|
5
5
|
import { clientIp, logActivity, type Session } from '../session.ts';
|
|
6
6
|
import { publicUrl } from '../sso.ts';
|
|
7
|
+
import { icon } from '../icons.ts';
|
|
7
8
|
import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
|
|
8
9
|
import { roleHints, roleHintsHtml, splitRoles } from './users.ts';
|
|
9
10
|
|
|
@@ -55,11 +56,19 @@ async function clientsRegion(s: Session, a: any, secret?: NewSecret, usable = tr
|
|
|
55
56
|
const when = (d: Date | string | null) => (d ? (d instanceof Date ? d.toISOString() : String(d)).slice(0, 16).replace('T', ' ') : '');
|
|
56
57
|
const action = (c: any, path: string, label: string, extra: Raw | '' = '', cls = 'btn') =>
|
|
57
58
|
html`<form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/${path}" class="inline-form">${csrf(s)}${extra}<button class="${cls}">${label}</button></form>`;
|
|
59
|
+
const createForm = html`<form method="post" action="${BASE}/apps/${a.id}/api/clients">${csrf(s)}
|
|
60
|
+
<div class="form-grid">
|
|
61
|
+
${input('name', 'Name', '', { required: true, placeholder: 'e.g. payroll-sync', help: 'Lower case letters, digits, . _ -' })}
|
|
62
|
+
${input('token_minutes', 'Token lifetime (minutes)', 60, { type: 'number', help: '5 to 1440.' })}
|
|
63
|
+
<div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" placeholder="comma separated, or pick below">${roleHintsHtml(hints)}</div>
|
|
64
|
+
${input('description', 'Description', '', { placeholder: 'who uses it' })}
|
|
65
|
+
</div>
|
|
66
|
+
<div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>Create client</button></div>
|
|
67
|
+
</form>`;
|
|
58
68
|
return region(
|
|
59
69
|
'OAuth clients',
|
|
60
|
-
html`<p class="muted u-mt0">For systems that call the API
|
|
61
|
-
<code>${tokenUrl}</code> (<code>grant_type=client_credentials</code>) and
|
|
62
|
-
The client acts as <code>client:<name></code> with the roles below, read at every request; revoking works at once.</p>
|
|
70
|
+
html`<p class="muted u-mt0">For systems that call the API (like ORDS's <code>oauth.create_client</code>): a client exchanges its id and secret for an access token at
|
|
71
|
+
<code>${tokenUrl}</code> (<code>grant_type=client_credentials</code>) and acts as <code>client:<name></code> with its roles, read at every request.</p>
|
|
63
72
|
${secret
|
|
64
73
|
? html`<div class="alert alert-success" role="status">${secret.rotated ? `New secret for ${secret.name}` : `Client ${secret.name} created`}. Copy the secret now: it is shown only once.${
|
|
65
74
|
secret.rotated && secret.graceHours ? ` The previous secret keeps working for ${secret.graceHours} hours.` : ''}</div>
|
|
@@ -70,30 +79,23 @@ async function clientsRegion(s: Session, a: any, secret?: NewSecret, usable = tr
|
|
|
70
79
|
<pre class="source">curl -u '${secret.clientId}:${secret.secret}' -d grant_type=client_credentials ${tokenUrl}</pre>`
|
|
71
80
|
: ''}
|
|
72
81
|
${clients.length
|
|
73
|
-
? html`<div class="table-wrap"><table class="report"><thead><tr><th>Client</th><th>Roles</th><th>Token</th><th>Secret</th><th>Last used</th><th></th></tr></thead><tbody>
|
|
82
|
+
? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th>Client</th><th>Roles</th><th class="num">Token</th><th>Secret changed</th><th>Last used</th><th><span class="sr-only">Actions</span></th></tr></thead><tbody>
|
|
74
83
|
${clients.map((c) => html`<tr${c.active ? '' : raw(' class="muted"')}>
|
|
75
|
-
<td data-label="Client"><strong>${c.name}</strong>${c.active ? '' : html` <span class="tag">revoked</span>`}<br><code>${c.client_id}</code>${c.description ? html`<br><small class="help">${c.description}</small>` : ''}</td>
|
|
84
|
+
<td data-label="Client"><strong>${c.name}</strong>${c.active ? '' : html` <span class="tag">revoked</span>`}<br><code class="small">${c.client_id}</code>${c.description ? html`<br><small class="help">${c.description}</small>` : ''}</td>
|
|
76
85
|
<td data-label="Roles"><form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/roles" class="search roles-form u-m0 u-mwnone">${csrf(s)}
|
|
77
|
-
<input name="roles" value="${c.roles.join(', ')}" aria-label="Roles of ${c.name}" placeholder="no roles"><button class="btn">Save</button>${roleHintsHtml(hints, 'Add')}</form></td>
|
|
78
|
-
<td data-label="Token">${c.token_minutes} min</td>
|
|
79
|
-
<td data-label="Secret">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">old secret valid until ${when(c.previous_valid_until)}</small>` : ''}</td>
|
|
86
|
+
<input name="roles" value="${c.roles.join(', ')}" aria-label="Roles of ${c.name}" placeholder="no roles"><button class="btn btn-sm">Save</button>${roleHintsHtml(hints, 'Add')}</form></td>
|
|
87
|
+
<td class="num" data-label="Token">${c.token_minutes} min</td>
|
|
88
|
+
<td data-label="Secret changed">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">old secret valid until ${when(c.previous_valid_until)}</small>` : ''}</td>
|
|
80
89
|
<td data-label="Last used">${when(c.last_used_at) || html`<span class="muted">never</span>`}</td>
|
|
81
|
-
<td class="actions">
|
|
82
|
-
${action(c, 'rotate', 'New secret', html`<select name="grace" aria-label="Keep the old secret"><option value="24">old valid 24 h</option><option value="168">old valid 7 days</option><option value="0">old invalid now</option></select
|
|
83
|
-
${action(c, 'active', c.active ? 'Revoke' : 'Reactivate', html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}"
|
|
84
|
-
${action(c, 'delete', 'Delete', '', 'btn btn-danger')}
|
|
85
|
-
</td></tr>`)}
|
|
86
|
-
</tbody></table></div
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
${input('name', 'Name', '', { required: true, placeholder: 'e.g. payroll-sync', help: 'Lower case letters, digits, . _ -' })}
|
|
91
|
-
<div class="field"><label class="label" for="f_roles">Roles</label><input id="f_roles" name="roles" placeholder="comma separated, or pick below">${roleHintsHtml(hints)}</div>
|
|
92
|
-
${input('token_minutes', 'Token lifetime (minutes)', 60, { type: 'number', help: '5 to 1440.' })}
|
|
93
|
-
${input('description', 'Description', '', { placeholder: 'who uses it' })}
|
|
94
|
-
</div>
|
|
95
|
-
<div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>Create client</button></div>
|
|
96
|
-
</form>`,
|
|
90
|
+
<td class="actions"><div class="api-client-actions">
|
|
91
|
+
${action(c, 'rotate', 'New secret', html`<select name="grace" aria-label="Keep the old secret"><option value="24">old valid 24 h</option><option value="168">old valid 7 days</option><option value="0">old invalid now</option></select>`, 'btn btn-sm')}
|
|
92
|
+
${action(c, 'active', c.active ? 'Revoke' : 'Reactivate', html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}">`, 'btn btn-sm')}
|
|
93
|
+
${action(c, 'delete', 'Delete', '', 'btn btn-sm btn-danger')}
|
|
94
|
+
</div></td></tr>`)}
|
|
95
|
+
</tbody></table></div>
|
|
96
|
+
<details class="api-new u-mt1"><summary class="btn">${icon('plus')} New client</summary><div class="api-new-body">${createForm}</div></details>`
|
|
97
|
+
: html`<p class="muted">No clients yet.</p>${createForm}`}`,
|
|
98
|
+
html`<span class="count">${clients.length}</span>`,
|
|
97
99
|
);
|
|
98
100
|
}
|
|
99
101
|
|
|
@@ -115,26 +117,22 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
|
|
|
115
117
|
`curl -X POST ${url}/rpc/${e.name} -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" -d '{ … }'`),
|
|
116
118
|
].join('\n');
|
|
117
119
|
|
|
118
|
-
const
|
|
120
|
+
const pill = (ok: boolean, text: Raw | string) => html`<li class="${ok ? 'is-ok' : 'is-bad'}"><span aria-hidden="true">${ok ? '✓' : '✗'}</span> <span class="sr-only">${ok ? 'OK:' : 'Problem:'}</span>${text}</li>`;
|
|
119
121
|
const main = html`${appHeader(a, 'api', s)}
|
|
120
|
-
<
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
122
|
+
<p class="muted api-intro">PostgREST runs next to pgkiln and serves the <code>${API_SCHEMA}</code> schema over HTTP. Requests carry a JWT whose <code>role</code> claim is this application’s API role; row level security uses the same <code>meta.app_user()</code> and <code>meta.has_role()</code> as the web pages.</p>
|
|
123
|
+
<ul class="api-status" aria-label="Status">
|
|
124
|
+
${pill(status.ok, html`PostgREST at <code>${url}</code>: ${status.ok ? status.detail : html`<b>not reachable</b> (${status.detail})`}`)}
|
|
125
|
+
${role ? pill(!problem, problem ?? html`<code>${role}</code> is a dedicated role`) : pill(false, 'No API role: tokens are for the REST modules pgkiln serves only')}
|
|
126
|
+
${role && !problem ? pill(!!granted?.ok, granted?.ok ? html`pgkiln_authenticator may switch to <code>${role}</code>` : html`<b>run</b> <code>grant ${role} to pgkiln_authenticator;</code>`) : ''}
|
|
127
|
+
</ul>
|
|
128
|
+
<div class="columns api-top">
|
|
129
|
+
${region('Setup', html`<form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
|
|
130
|
+
${input('api_role', 'API database role', role, { placeholder: 'e.g. myapp_api', help: 'Grant it only the api schema’s views and functions, and grant it to pgkiln_authenticator. Empty: tokens work only for the REST modules pgkiln serves (Shared Components → REST modules).' })}
|
|
131
|
+
<div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> Users create their own tokens</label>
|
|
132
|
+
<small class="help">On <em>My account</em>, signed-in users create a token that acts as them. A user the application requires two-factor sign-in of gives a second step first (if their last one is older than 15 minutes).</small></div>
|
|
129
133
|
<div class="buttons"><button class="btn btn-hot">Save</button></div>
|
|
130
|
-
</form
|
|
131
|
-
|
|
132
|
-
${check(status.ok, html`PostgREST at <code>${url}</code>: ${status.ok ? status.detail : html`<b>not reachable</b> (${status.detail})`}`)}
|
|
133
|
-
${role ? check(!problem, problem ?? html`<code>${role}</code> is a dedicated role`) : check(false, 'No API role: tokens are for the REST modules pgkiln serves only')}
|
|
134
|
-
${role && !problem ? check(!!granted?.ok, granted?.ok ? html`pgkiln_authenticator may switch to <code>${role}</code>` : html`<b>run</b> <code>grant ${role} to pgkiln_authenticator;</code>`) : ''}
|
|
135
|
-
</ul>`)}
|
|
136
|
-
${region('Issue a token', html`
|
|
137
|
-
<p class="muted u-mt0">For development and trusted scripts. The token acts as the account in this application until it expires. Roles are read at each request, and deactivating the account or revoking its access stops the token at once (PostgREST’s pre-request check <code>meta.api_check</code>). To invalidate all tokens, change <code>API_JWT_SECRET</code>.</p>
|
|
134
|
+
</form>`)}
|
|
135
|
+
${region('Issue a token', html`<p class="muted u-mt0">For development and trusted scripts: the token acts as the account until it expires. Roles are read at each request; deactivating the account or revoking its access stops the token at once (<code>meta.api_check</code>). To invalidate all tokens, change <code>API_JWT_SECRET</code>.</p>
|
|
138
136
|
<form method="post" action="${BASE}/apps/${a.id}/api/token">${csrf(s)}
|
|
139
137
|
<div class="form-grid">
|
|
140
138
|
${input('username', 'Account', issued?.username ?? '', { required: true, auto: 'off' })}
|
|
@@ -145,13 +143,15 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
|
|
|
145
143
|
${issued ? html`<div class="field u-mt1" data-wide><label class="label" for="api_token">Token for ${issued.username} (shown once, valid ${issued.expiresInHours} h)</label>
|
|
146
144
|
<textarea id="api_token" class="code" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>` : ''}`)}
|
|
147
145
|
</div>
|
|
148
|
-
${
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
<p class="muted">
|
|
146
|
+
${region('Endpoints', html`${eps.length
|
|
147
|
+
? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Path</th><th>Kind</th><th>Methods for ${role}</th></tr></thead>
|
|
148
|
+
<tbody>${eps.map((e) => html`<tr><td data-label="Path"><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td data-label="Kind">${e.kind}</td><td data-label="Methods">${e.methods || html`<span class="muted">none</span>`}</td></tr>`)}</tbody></table></div>`
|
|
149
|
+
: html`<p class="muted u-mt0">Nothing in the <code>${API_SCHEMA}</code> schema yet${role ? '' : ', or no API role set'}. Create views and functions there and grant them to the API role.</p>`}
|
|
150
|
+
<h3 class="api-sub">Try it</h3>
|
|
151
|
+
<pre class="source">${examples}</pre>
|
|
152
|
+
<p class="muted small">After schema changes run <code>notify pgrst, 'reload schema';</code> so PostgREST picks them up. OpenAPI description: <a href="${url}/" target="_blank" rel="noopener">${url}/</a>.</p>`,
|
|
153
|
+
html`<span class="count">${eps.length}</span>`)}
|
|
154
|
+
${await clientsRegion(s, a, secret, !problem)}`;
|
|
155
155
|
return shell(s, `${a.name} REST API`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['REST API']], main);
|
|
156
156
|
}
|
|
157
157
|
|