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.
Files changed (99) hide show
  1. package/db/migrations/153_component_events.sql +21 -0
  2. package/db/migrations/154_notifications.sql +186 -0
  3. package/docs/apex-feature-parity.md +4 -3
  4. package/docs/guide/03-builder.md +10 -1
  5. package/docs/guide/04-pages-and-regions.md +44 -1
  6. package/docs/guide/06-processing.md +22 -1
  7. package/docs/guide/07-dynamic-actions.md +28 -0
  8. package/docs/guide/09-reference.md +15 -0
  9. package/docs/guide/11-from-apex.md +3 -1
  10. package/docs/guide/12-development.md +16 -1
  11. package/package.json +1 -1
  12. package/public/app.css +31 -0
  13. package/public/app.js +163 -4
  14. package/public/builder.css +25 -2
  15. package/public/data-modeler.js +8 -5
  16. package/public/sql-monitor.js +5 -2
  17. package/public/sw.js +5 -1
  18. package/src/app.ts +2 -0
  19. package/src/builder/ai-builder.ts +50 -46
  20. package/src/builder/api.ts +49 -49
  21. package/src/builder/autorest.ts +92 -87
  22. package/src/builder/changelog.ts +39 -37
  23. package/src/builder/code-editor.ts +1 -1
  24. package/src/builder/components.ts +13 -8
  25. package/src/builder/data-modeler.ts +89 -84
  26. package/src/builder/data-security.ts +39 -37
  27. package/src/builder/dataload.ts +69 -65
  28. package/src/builder/datapump.ts +108 -97
  29. package/src/builder/ddl-tools.ts +38 -34
  30. package/src/builder/document-view.ts +11 -11
  31. package/src/builder/i18n/de.ts +1635 -0
  32. package/src/builder/i18n/en.ts +1635 -0
  33. package/src/builder/i18n/es.ts +1635 -0
  34. package/src/builder/i18n/fr.ts +1635 -0
  35. package/src/builder/i18n/it.ts +1635 -0
  36. package/src/builder/i18n/ja.ts +1635 -0
  37. package/src/builder/i18n/ko.ts +1635 -0
  38. package/src/builder/i18n/nl.ts +1635 -0
  39. package/src/builder/i18n/pt.ts +1635 -0
  40. package/src/builder/i18n/zh-cn.ts +1635 -0
  41. package/src/builder/i18n/zh-tw.ts +1635 -0
  42. package/src/builder/i18n.ts +18 -0
  43. package/src/builder/notification-settings.ts +27 -0
  44. package/src/builder/object-actions.ts +18 -16
  45. package/src/builder/object-browser.ts +2 -2
  46. package/src/builder/querybuilder.ts +42 -36
  47. package/src/builder/quicksql.ts +16 -18
  48. package/src/builder/region-settings.ts +201 -199
  49. package/src/builder/report-settings.ts +64 -60
  50. package/src/builder/rest-modules.ts +145 -143
  51. package/src/builder/routes.ts +3 -0
  52. package/src/builder/sampledata.ts +59 -52
  53. package/src/builder/scheduling.ts +180 -159
  54. package/src/builder/scripts.ts +71 -65
  55. package/src/builder/sql-charts.ts +36 -30
  56. package/src/builder/sql-monitor.ts +95 -89
  57. package/src/builder/sql-utilities.ts +47 -40
  58. package/src/builder/top-sql.ts +21 -17
  59. package/src/builder/unload.ts +48 -44
  60. package/src/builder/worksheet.ts +3 -3
  61. package/src/cli/replace.ts +2 -0
  62. package/src/cron-schedule.ts +47 -17
  63. package/src/i18n/ar.ts +9 -0
  64. package/src/i18n/cs.ts +9 -0
  65. package/src/i18n/da.ts +9 -0
  66. package/src/i18n/de.ts +9 -0
  67. package/src/i18n/el.ts +9 -0
  68. package/src/i18n/es.ts +9 -0
  69. package/src/i18n/fi.ts +9 -0
  70. package/src/i18n/fr.ts +9 -0
  71. package/src/i18n/he.ts +9 -0
  72. package/src/i18n/it.ts +9 -0
  73. package/src/i18n/ja.ts +9 -0
  74. package/src/i18n/ko.ts +9 -0
  75. package/src/i18n/nb.ts +9 -0
  76. package/src/i18n/pl.ts +9 -0
  77. package/src/i18n/pt.ts +9 -0
  78. package/src/i18n/ru.ts +9 -0
  79. package/src/i18n/sv.ts +9 -0
  80. package/src/i18n/tr.ts +9 -0
  81. package/src/i18n/uk.ts +9 -0
  82. package/src/i18n/zh.ts +9 -0
  83. package/src/i18n.ts +18 -0
  84. package/src/metadata.ts +11 -3
  85. package/src/runtime/calendar.ts +3 -2
  86. package/src/runtime/context.ts +2 -0
  87. package/src/runtime/engine.ts +2 -1
  88. package/src/runtime/feedback.ts +1 -1
  89. package/src/runtime/grid.ts +1 -0
  90. package/src/runtime/lists.ts +43 -11
  91. package/src/runtime/maps.ts +7 -4
  92. package/src/runtime/notifications.ts +237 -0
  93. package/src/runtime/processes.ts +45 -1
  94. package/src/runtime/regions.ts +11 -15
  95. package/src/runtime/render.ts +18 -4
  96. package/src/runtime/routes.ts +16 -4
  97. package/src/runtime/signin.ts +2 -1
  98. package/src/runtime/tree.ts +4 -3
  99. 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">AI service: <b>${svc.name}</b> (${providerLabel(svc.provider)}, ${svc.model}). Table and column names, types and descriptions, and what you type, are sent to it.</p>`
120
- : html`<div class="alert alert-info">The App Builder has no AI service yet: ask an administrator to choose one here.</div>`;
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">AI service of the App Builder (administrators)</label>
124
- <select id="f_ai_builder_service" name="service">${opt('', '- none: no AI in the App Builder -', svc?.name ?? '')}${all.map((x) => opt(x.name, `${x.name} (${providerLabel(x.provider)}, ${x.model})`, svc?.name ?? ''))}</select>
125
- <small class="help">Table and column names, types and descriptions (never rows), and what developers type, are sent to it. Its calls are logged without an application.</small></div></div>
126
- <div class="buttons"><button class="btn">Save</button></div></form>`;
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 main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai', s)}
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">Describe tables for AI ▸</a> <span class="muted">(better answers: say what tables and columns mean)</span></p>
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('SQL from a question', html`
151
- <p class="muted u-mt0">The answer is shown, never run: check it, then run it in the SQL Worksheet if you want.</p>
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', 'Schema', out.schema ?? list[0] ?? '', list)}</div>
154
- <div class="field"><label class="label" for="f_question">Question</label>
155
- <textarea id="f_question" name="question" rows="4" maxlength="4000" required placeholder="e.g. employees per department with their average salary">${out.question ?? ''}</textarea></div>
156
- <div class="buttons"><button class="btn btn-hot" data-busy="Thinking…">Write the SQL</button></div>
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('Explain a query or an error', html`
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', 'Schema (optional)', out.schema ?? '', [['', '- none -'], ...list.map((x): [string, string] => [x, x])])}</div>
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">Error message (optional)</label>
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="Thinking…">Explain</button></div>
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, 'SQL Workshop', [['SQL Workshop', `${BASE}/sql`], ['AI']], main, 'sql'));
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 main = html`<h1 class="u-mb1">SQL Workshop</h1>${workshopTabs('ai', s)}
182
- <p><a href="${BASE}/sql/ai">◂ SQL Workshop AI</a></p>
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('Describe tables for AI', html`
185
- <p class="muted u-mt0">Say what tables and columns mean: the descriptions go with the table list when the App Builder asks a model for SQL or pages. Without one, the database comment is used.</p>
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', 'Schema', schema, [['', '- choose -'], ...list.map((x): [string, string] => [x, x])])}
188
- </div><div class="buttons"><button class="btn">Show tables</button></div></form>
189
- ${tables.length ? html`<ul class="ai-tables">${tables.map((x) => html`<li><a href="${BASE}/sql/ai/describe?schema=${encodeURIComponent(schema)}&amp;table=${encodeURIComponent(x.table)}"${x.table === table ? raw(' aria-current="true"') : ''}>${x.table}</a> <span class="muted">${x.kind}${noted.has(x.table) ? ', described' : ''}</span></li>`)}</ul>` : ''}`)}
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)}&amp;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">Drafted by AI from the names and types: check and change the descriptions, then save.</div>` : ''}
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_">The ${t.kind}</label>
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">Column</th><th scope="col">Description</th></tr></thead><tbody>
197
- ${t.columns.map((c) => html`<tr><td data-label="Column"><code>${c.name}</code> <span class="muted">${c.type}${c.pk ? ', key' : ''}${c.fk ? `, → ${c.fk}` : ''}</span></td>
198
- <td data-label="Description"><input name="note:${c.name}" maxlength="2000" value="${value(c.name, c.note)}" aria-label="Description of ${c.name}"></td></tr>`)}
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"> Also save them as database comments (COMMENT ON)</label></div>
201
- <div class="buttons"><button class="btn btn-hot">Save the descriptions</button>
202
- <button class="btn" formaction="${BASE}/sql/ai/describe/draft" data-busy="Thinking…">Draft with AI</button></div>
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, 'SQL Workshop', [['SQL Workshop', `${BASE}/sql`], ['AI', `${BASE}/sql/ai`], ['Describe tables']], main, 'sql'));
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, `There is no AI service ${name}.`, 'error');
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 ? `The App Builder uses AI service ${name}.` : 'The App Builder uses no AI service.');
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', 'Type a question.');
313
- if (!(await schemas(s)).includes(schema)) throw new AiError('config', 'Choose a schema.');
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('Proposed SQL', html`
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">SQL (not run)</label>
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">Run in the SQL Worksheet</button></div>
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', 'Paste a query, an error message or both.');
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('Explanation', html`<div class="ai-answer">${formatAnswer(res.text)}</div>`);
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, `Descriptions of ${t.schema}.${t.table} saved${b.comments === 'true' ? ', also as database comments' : ''}.`);
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, `Not saved: ${(e as Error).message}`, 'error');
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
  });
@@ -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, like ORDS's <code>oauth.create_client</code>. A client exchanges its id and secret for an access token at
61
- <code>${tokenUrl}</code> (<code>grant_type=client_credentials</code>) and fetches a new one when it expires, so tokens never need rotating by hand.
62
- The client acts as <code>client:&lt;name&gt;</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:&lt;name&gt;</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
- : html`<p class="muted">No clients yet.</p>`}
88
- <form method="post" action="${BASE}/apps/${a.id}/api/clients" class="u-mt1">${csrf(s)}
89
- <div class="form-grid">
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 check = (ok: boolean, text: Raw | string) => html`<li>${ok ? '✓' : '✗'} ${text}</li>`;
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
- <div class="columns">
121
- ${region('Setup', html`
122
- <p class="muted u-mt0">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
- <form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
124
- <div class="form-grid">
125
- ${input('api_role', 'API database role', role, { placeholder: 'e.g. myapp_api', help: 'For PostgREST: grant it only the api schema’s views and functions, and grant it to pgkiln_authenticator. Empty: tokens work for the REST modules pgkiln serves (Shared Components → REST modules) only.' })}
126
- <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> Users create their own tokens</label>
127
- <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); for such users this is the only way to get a token.</small></div>
128
- </div>
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
- <ul class="checklist u-mt1">
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
- ${await clientsRegion(s, a, secret, !problem)}
149
- ${region('Endpoints', eps.length
150
- ? html`<div class="table-wrap"><table class="report"><thead><tr><th>Path</th><th>Kind</th><th>Methods for ${role}</th></tr></thead>
151
- <tbody>${eps.map((e) => html`<tr><td><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td>${e.kind}</td><td>${e.methods || html`<span class="muted">none</span>`}</td></tr>`)}</tbody></table></div>`
152
- : html`<p class="muted">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>`)}
153
- ${region('Try it', html`<pre class="source">${examples}</pre>
154
- <p class="muted">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>`)}`;
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