pgkiln 0.84.0 → 0.86.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 (105) hide show
  1. package/db/migrations/155_mail_available.sql +23 -0
  2. package/docs/apex-feature-parity.md +2 -2
  3. package/docs/guide/03-builder.md +14 -5
  4. package/docs/guide/04-pages-and-regions.md +1 -1
  5. package/docs/guide/06-processing.md +7 -3
  6. package/docs/guide/07-dynamic-actions.md +3 -3
  7. package/docs/guide/09-reference.md +1 -0
  8. package/docs/guide/12-development.md +10 -1
  9. package/examples/gallery/README.md +23 -6
  10. package/examples/gallery/gallery.json +4 -0
  11. package/examples/gallery/samples/sample-approvals.sql +102 -11
  12. package/examples/gallery/samples/sample-calendar.sql +40 -5
  13. package/examples/gallery/samples/sample-dynamic-actions.sql +237 -9
  14. package/examples/gallery/samples/sample-grids.sql +35 -3
  15. package/examples/gallery/samples/sample-maps.sql +65 -7
  16. package/examples/gallery/samples/sample-processes.sql +466 -0
  17. package/examples/gallery/samples/sample-validations.sql +407 -0
  18. package/examples/gallery/samples/ux-patterns.sql +154 -14
  19. package/examples/hr/README.md +2 -0
  20. package/examples/hr/hr_80_notifications.sql +125 -0
  21. package/examples/hr/hr_81_vector_search_type.sql +33 -0
  22. package/package.json +1 -1
  23. package/public/app.css +8 -1
  24. package/public/app.js +28 -9
  25. package/src/app.ts +9 -0
  26. package/src/builder/ai-agents.ts +86 -85
  27. package/src/builder/ai-builder.ts +30 -30
  28. package/src/builder/ai.ts +93 -92
  29. package/src/builder/api.ts +47 -47
  30. package/src/builder/app-groups.ts +33 -32
  31. package/src/builder/appfromfile.ts +57 -58
  32. package/src/builder/appsheets.ts +33 -32
  33. package/src/builder/appwizard.ts +65 -65
  34. package/src/builder/arrange.ts +23 -22
  35. package/src/builder/assistant.ts +39 -38
  36. package/src/builder/automations.ts +32 -31
  37. package/src/builder/availability.ts +24 -24
  38. package/src/builder/backups.ts +30 -31
  39. package/src/builder/blueprints.ts +62 -61
  40. package/src/builder/create-page.ts +52 -50
  41. package/src/builder/cross-app.ts +37 -35
  42. package/src/builder/dataload.ts +2 -2
  43. package/src/builder/datasets.ts +107 -106
  44. package/src/builder/dependencies.ts +20 -19
  45. package/src/builder/designer.ts +36 -36
  46. package/src/builder/diagnostics.ts +58 -57
  47. package/src/builder/features.ts +39 -35
  48. package/src/builder/feedback.ts +23 -20
  49. package/src/builder/find-workspace.ts +28 -24
  50. package/src/builder/form-designer.ts +36 -35
  51. package/src/builder/gallery.ts +25 -29
  52. package/src/builder/globalization.ts +50 -49
  53. package/src/builder/i18n/de.ts +3453 -0
  54. package/src/builder/i18n/en.ts +3453 -0
  55. package/src/builder/i18n/es.ts +3453 -0
  56. package/src/builder/i18n/fr.ts +3453 -0
  57. package/src/builder/i18n/it.ts +3453 -0
  58. package/src/builder/i18n/ja.ts +3453 -0
  59. package/src/builder/i18n/ko.ts +3453 -0
  60. package/src/builder/i18n/nl.ts +3453 -0
  61. package/src/builder/i18n/pt.ts +3453 -0
  62. package/src/builder/i18n/zh-cn.ts +3453 -0
  63. package/src/builder/i18n/zh-tw.ts +3453 -0
  64. package/src/builder/i18n.ts +17 -3
  65. package/src/builder/json-sources.ts +63 -62
  66. package/src/builder/layouts.ts +12 -11
  67. package/src/builder/locks.ts +29 -28
  68. package/src/builder/login-page.ts +26 -25
  69. package/src/builder/mail.ts +148 -147
  70. package/src/builder/newapp.ts +15 -12
  71. package/src/builder/plugins.ts +47 -44
  72. package/src/builder/process-jobs.ts +11 -10
  73. package/src/builder/pwa.ts +22 -21
  74. package/src/builder/remote-databases.ts +55 -56
  75. package/src/builder/report-settings.ts +1 -1
  76. package/src/builder/reporter.ts +43 -42
  77. package/src/builder/rest-catalogs.ts +87 -86
  78. package/src/builder/rest.ts +9 -8
  79. package/src/builder/routes.ts +27 -27
  80. package/src/builder/search.ts +14 -13
  81. package/src/builder/static-files.ts +31 -32
  82. package/src/builder/subscriptions.ts +47 -45
  83. package/src/builder/supporting.ts +23 -18
  84. package/src/builder/team.ts +179 -179
  85. package/src/builder/telemetry.ts +59 -62
  86. package/src/builder/templates.ts +66 -65
  87. package/src/builder/themeroller.ts +60 -63
  88. package/src/builder/ui-defaults.ts +21 -20
  89. package/src/builder/ui.ts +2 -1
  90. package/src/builder/upgrade.ts +25 -24
  91. package/src/builder/utilities.ts +48 -47
  92. package/src/builder/vector-providers.ts +67 -66
  93. package/src/builder/websources.ts +43 -42
  94. package/src/builder/wizards.ts +95 -89
  95. package/src/builder/workflow-migrate.ts +30 -31
  96. package/src/builder/workflows.ts +40 -39
  97. package/src/builder/workingcopies.ts +41 -49
  98. package/src/builder/workspace-backups.ts +21 -20
  99. package/src/builder/workspace-mail.ts +17 -16
  100. package/src/builder/workspace-resources.ts +104 -112
  101. package/src/builder/workspace-transfer.ts +34 -26
  102. package/src/runtime/engine.ts +6 -3
  103. package/src/runtime/lists.ts +2 -2
  104. package/src/runtime/push.ts +7 -1
  105. package/src/runtime/regions.ts +9 -4
package/src/builder/ai.ts CHANGED
@@ -10,6 +10,7 @@ import { encryptSecret, secretKeyConfigured } from '../secrets.ts';
10
10
  import type { Session } from '../session.ts';
11
11
  import { isAdmin } from './locks.ts';
12
12
  import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
13
+ import { scopedTx as tx } from './i18n.ts';
13
14
 
14
15
  // Workspace utilities → AI services (administrators): the AI services of
15
16
  // this installation (Claude, OpenAI, Gemini or an OpenAI-compatible endpoint,
@@ -18,8 +19,8 @@ import { back, BASE, csrf, developer, flash, input, region, select, send, shell,
18
19
  // usage log. Applications → Activity → AI usage (developers): what an application may use and what it used.
19
20
  // API keys are encrypted (src/secrets.ts) and never sent back to the browser.
20
21
 
21
- const PROVIDERS: [string, string][] = [['anthropic', 'Claude (Anthropic)'], ['openai', 'OpenAI'], ['gemini', 'Gemini (Google, OpenAI-compatible endpoint)'], ['openai_compatible', 'OpenAI-compatible (Ollama, vLLM, Azure OpenAI, a gateway)']];
22
- const EFFORTS: [string, string][] = [['', 'Model default'], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
22
+ const PROVIDERS = (): [string, string][] => [['anthropic', tx('aisv.claude_anthropic')], ['openai', tx('aisv.openai')], ['gemini', tx('aisv.gemini_google_openai_compatible_endpoint')], ['openai_compatible', tx('aisv.openai_compatible_ollama_vllm_azure')]];
23
+ const EFFORTS = (): [string, string][] => [['', tx('aisv.model_default')], ['low', 'low'], ['medium', 'medium'], ['high', 'high'], ['xhigh', 'xhigh'], ['max', 'max']];
23
24
  const TEST_RESULT = '__AITEST';
24
25
 
25
26
  const check = (name: string, label: string, on: boolean, help?: string) =>
@@ -32,25 +33,25 @@ const KEY_TEXT = { stored: 'stored (encrypted)', env: 'server default', missing:
32
33
  function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean) {
33
34
  const source = s.provider ? keySource(s as AiService) : null;
34
35
  return html`<form method="post" action="${action}">${csrfField}
35
- <fieldset class="prop-group"><legend>Service</legend><div class="form-grid">
36
- ${isNew ? input('name', 'Name', '', { required: true, placeholder: 'e.g. CLAUDE', help: 'Upper case letters, digits and _. Processes refer to the service by this name.' }) : ''}
37
- ${input('description', 'Description', s.description)}
38
- ${select('provider', 'Provider', s.provider ?? 'anthropic', PROVIDERS, 'Prompts, and the item values they contain, are sent to this provider. Gemini and OpenAI-compatible endpoints speak OpenAI\'s Chat Completions.')}
39
- ${input('model', 'Model', s.model ?? (isNew ? DEFAULT_CLAUDE_MODEL : ''), { required: true, help: `Exactly as the provider names it, e.g. ${DEFAULT_CLAUDE_MODEL} (Claude) or the OpenAI model your account uses. pgkiln never changes it.` })}
40
- ${select('effort', 'Effort (Claude)', isNew ? 'medium' : s.effort ?? '', EFFORTS, 'How deeply Claude thinks (thinking is always on for current Claude models): lower is faster and cheaper; high or more for hard tasks. Model default: medium for Claude Opus 5.5.')}
41
- ${input('max_tokens', 'Maximum output tokens', s.max_tokens ?? 4000, { type: 'number', help: 'Per request (1 – 128000); a process may ask for fewer.' })}
42
- ${input('timeout_s', 'Time limit (seconds)', s.timeout_s ?? 120, { type: 'number', help: '5 – 600. Pages wait for the answer.' })}
43
- ${input('base_url', 'Base URL', s.base_url, { placeholder: 'empty: the provider\'s own API', help: `A gateway or proxy that speaks the provider's API. Administrators only. Gemini: empty is ${GEMINI_BASE_URL}. OpenAI-compatible: required, e.g. http://localhost:11434/v1 (Ollama), https://<resource>.openai.azure.com/openai/v1/ (Azure OpenAI).` })}
36
+ <fieldset class="prop-group"><legend>${tx('aisv.service')}</legend><div class="form-grid">
37
+ ${isNew ? input('name', tx('aisv.name'), '', { required: true, placeholder: tx('aisv.e_g_claude'), help: tx('aisv.upper_case_letters_digits_and') }) : ''}
38
+ ${input('description', tx('aisv.description'), s.description)}
39
+ ${select('provider', tx('aisv.provider'), s.provider ?? 'anthropic', PROVIDERS(), tx('aisv.prompts_and_the_item_values'))}
40
+ ${input('model', tx('aisv.model'), s.model ?? (isNew ? DEFAULT_CLAUDE_MODEL : ''), { required: true, help: tx('aisv.model_help', { model: DEFAULT_CLAUDE_MODEL }) })}
41
+ ${select('effort', tx('aisv.effort_claude'), isNew ? 'medium' : s.effort ?? '', EFFORTS(), tx('aisv.how_deeply_claude_thinks_thinking'))}
42
+ ${input('max_tokens', tx('aisv.maximum_output_tokens'), s.max_tokens ?? 4000, { type: 'number', help: tx('aisv.per_request_1_128000_a') })}
43
+ ${input('timeout_s', tx('aisv.time_limit_seconds'), s.timeout_s ?? 120, { type: 'number', help: tx('aisv.5_600_pages_wait_for') })}
44
+ ${input('base_url', tx('aisv.base_url'), s.base_url, { placeholder: tx('aisv.empty_the_provider_s_own'), help: `A gateway or proxy that speaks the provider's API. Administrators only. Gemini: empty is ${GEMINI_BASE_URL}. OpenAI-compatible: required, e.g. http://localhost:11434/v1 (Ollama), https://<resource>.openai.azure.com/openai/v1/ (Azure OpenAI).` })}
44
45
  </div>
45
- ${check('refusal_fallback', 'Refusal fallbacks (Claude)', s.refusal_fallback !== false, 'When Claude declines a request for safety reasons, Anthropic re-runs it on its recommended fallback model. The usage log shows the model that answered.')}
46
- ${check('enabled', 'Enabled', s.enabled !== false)}
46
+ ${check('refusal_fallback', tx('aisv.refusal_fallbacks_claude'), s.refusal_fallback !== false, tx('aisv.when_claude_declines_a_request'))}
47
+ ${check('enabled', tx('aisv.enabled'), s.enabled !== false)}
47
48
  </fieldset>
48
- <fieldset class="prop-group"><legend>API key</legend>
49
- ${input('api_key', 'API key', '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as ProviderName) ?? 'anthropic')} is used (when set)${s.provider === 'openai_compatible' ? '; an endpoint without keys (Ollama) needs none' : ''}.` })}
50
- ${source === 'stored' ? check('remove_key', 'Remove the stored key (use the server default)', false) : ''}
51
- ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: API keys can't be stored until it is set (the server's environment variables still work).</div>`}
49
+ <fieldset class="prop-group"><legend>${tx('aisv.api_key')}</legend>
50
+ ${input('api_key', tx('aisv.api_key'), '', { type: 'password', auto: 'new-password', help: source === 'stored' ? 'A key is stored (encrypted). It is never shown; type a new one to replace it.' : `Empty: the server's ${envKeyName((s.provider as ProviderName) ?? 'anthropic')} is used (when set)${s.provider === 'openai_compatible' ? '; an endpoint without keys (Ollama) needs none' : ''}.` })}
51
+ ${source === 'stored' ? check('remove_key', tx('aisv.remove_the_stored_key_use'), false) : ''}
52
+ ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('aisv.the_server_has_no_pgkiln')}</div>`}
52
53
  </fieldset>
53
- <div class="buttons"><button class="btn btn-hot">${isNew ? 'Add service' : 'Save'}</button></div>
54
+ <div class="buttons"><button class="btn btn-hot">${isNew ? tx('aisv.add_service') : tx('aisv.save')}</button></div>
54
55
  </form>`;
55
56
  }
56
57
 
@@ -58,33 +59,33 @@ function serviceForm(s: Partial<AiService>, action: string, csrfField: ReturnTyp
58
59
  function formValues(b: Record<string, string | undefined>, hasServer = false) {
59
60
  const provider: ProviderName = (['openai', 'gemini', 'openai_compatible'] as const).find((p) => p === b.provider) ?? 'anthropic';
60
61
  const model = (b.model ?? '').trim();
61
- if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error('The model is a name like claude-opus-5-5 (letters, digits and . _ : / @ -).');
62
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:/@-]{0,199}$/.test(model)) throw new Error(tx('aisv.the_model_is_a_name'));
62
63
  const maxTokens = Number(b.max_tokens || 4000);
63
- if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 128000) throw new Error('Maximum output tokens: 1 to 128000.');
64
+ if (!Number.isInteger(maxTokens) || maxTokens < 1 || maxTokens > 128000) throw new Error(tx('aisv.maximum_output_tokens_1_to'));
64
65
  const timeout = Number(b.timeout_s || 120);
65
- if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error('Time limit: 5 to 600 seconds.');
66
+ if (!Number.isInteger(timeout) || timeout < 5 || timeout > 600) throw new Error(tx('aisv.time_limit_5_to_600'));
66
67
  const baseUrl = (b.base_url ?? '').trim() || null;
67
- if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error('The base URL starts with http:// or https://.');
68
- if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error('An OpenAI-compatible service needs a base URL (e.g. http://localhost:11434/v1 for Ollama).');
68
+ if (baseUrl && (!/^https?:\/\/[^\s]+$/i.test(baseUrl) || baseUrl.length > 500)) throw new Error(tx('aisv.the_base_url_starts_with'));
69
+ if (!baseUrl && provider === 'openai_compatible' && !hasServer) throw new Error(tx('aisv.an_openai_compatible_service_needs_2'));
69
70
  const effort = ['low', 'medium', 'high', 'xhigh', 'max'].includes(b.effort ?? '') ? b.effort! : null;
70
71
  return [(b.description ?? '').trim() || null, provider, model, provider === 'anthropic' ? effort : null, b.refusal_fallback === 'true', maxTokens, timeout, baseUrl, b.enabled === 'true'];
71
72
  }
72
73
  const COLUMNS = 'description, provider, model, effort, refusal_fallback, max_tokens, timeout_s, base_url, enabled';
73
74
 
74
75
  const usageTable = (rows: any[], withApp: boolean) => html`<div class="table-wrap"><table class="report report-reflow">
75
- <thead><tr><th>When (UTC)</th>${withApp ? html`<th>Application</th>` : ''}<th class="num">Page</th><th>User</th><th>Service</th><th>Model</th><th>Source</th><th class="num">Input</th><th class="num">Output</th><th class="num">ms</th><th>Status</th></tr></thead>
76
+ <thead><tr><th>${tx('aisv.when_utc')}</th>${withApp ? html`<th>${tx('aisv.application')}</th>` : ''}<th class="num">${tx('aisv.page')}</th><th>${tx('aisv.user')}</th><th>${tx('aisv.service')}</th><th>${tx('aisv.model')}</th><th>${tx('aisv.source')}</th><th class="num">${tx('aisv.input')}</th><th class="num">${tx('aisv.output')}</th><th class="num">ms</th><th>${tx('aisv.status')}</th></tr></thead>
76
77
  <tbody>${rows.length ? rows.map((u) => html`<tr>
77
- <td data-label="When">${when(u.at)}</td>${withApp ? html`<td data-label="Application">${u.app ?? html`<span class="muted">builder</span>`}</td>` : ''}
78
- <td class="num" data-label="Page">${u.page_no ?? ''}</td><td data-label="User">${u.username ?? ''}</td><td data-label="Service">${u.service}</td>
79
- <td data-label="Model">${u.model}</td><td data-label="Source">${u.source}</td>
80
- <td class="num" data-label="Input">${u.input_tokens}</td><td class="num" data-label="Output">${u.output_tokens}</td><td class="num" data-label="ms">${u.duration_ms}</td>
81
- <td data-label="Status"><span class="tag${u.status === 'ok' ? ' tag-ok' : ' tag-error'}">${u.status}</span>${u.message ? html` <span class="muted">${u.message}</span>` : ''}</td></tr>`)
82
- : html`<tr><td colspan="${withApp ? 11 : 10}" class="empty">No AI requests yet.</td></tr>`}</tbody></table></div>`;
78
+ <td data-label="${tx('aisv.when')}">${when(u.at)}</td>${withApp ? html`<td data-label="${tx('aisv.application')}">${u.app ?? html`<span class="muted">builder</span>`}</td>` : ''}
79
+ <td class="num" data-label="${tx('aisv.page')}">${u.page_no ?? ''}</td><td data-label="${tx('aisv.user')}">${u.username ?? ''}</td><td data-label="${tx('aisv.service')}">${u.service}</td>
80
+ <td data-label="${tx('aisv.model')}">${u.model}</td><td data-label="${tx('aisv.source')}">${u.source}</td>
81
+ <td class="num" data-label="${tx('aisv.input')}">${u.input_tokens}</td><td class="num" data-label="${tx('aisv.output')}">${u.output_tokens}</td><td class="num" data-label="ms">${u.duration_ms}</td>
82
+ <td data-label="${tx('aisv.status')}"><span class="tag${u.status === 'ok' ? ' tag-ok' : ' tag-error'}">${u.status}</span>${u.message ? html` <span class="muted">${u.message}</span>` : ''}</td></tr>`)
83
+ : html`<tr><td colspan="${withApp ? 11 : 10}" class="empty">${tx('aisv.no_ai_requests_yet')}</td></tr>`}</tbody></table></div>`;
83
84
 
84
85
  async function adminOnly(s: Session, reply: any, title: string) {
85
86
  if (await isAdmin(s.username)) return true;
86
- const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">Only administrators configure AI services.</div>`;
87
- await send(reply.code(403), s, shell(s, title, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [title]], main));
87
+ const main = html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('aisv.only_administrators_configure_ai_services')}</div>`;
88
+ await send(reply.code(403), s, shell(s, title, [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [title]], main));
88
89
  return false;
89
90
  }
90
91
 
@@ -100,27 +101,27 @@ export async function aiRoutes(app: FastifyInstance) {
100
101
  group by 1, 2 order by 1 nulls first, 2`)).rows;
101
102
  const recent = (await owner.query(`select u.*, a.alias as app from meta.ai_usage u left join meta.app a on a.id = u.app_id order by u.id desc limit 100`)).rows;
102
103
  const main = html`
103
- <div class="title-row"><h1>AI services</h1></div>
104
- <p class="muted u-mt0">Large language models the applications of this installation may use (processes and dynamic actions <b>Generate text with AI</b>, and <code>meta.ai_generate()</code> from SQL). Prompts, including the item values they contain, are sent to the chosen provider. Allow each application on the service's page.</p>
104
+ <div class="title-row"><h1>${tx('aisv.ai_services')}</h1></div>
105
+ <p class="muted u-mt0">${tx.rich('aisv.intro', { process: html`<b>${tx('aisv.generate_text_with_ai')}</b>`, fn: html`<code>meta.ai_generate()</code>` })}</p>
105
106
  <div class="columns wide-left">
106
- ${region('Services', html`<div class="table-wrap"><table class="report report-reflow">
107
- <thead><tr><th>Service</th><th>Provider</th><th>Model</th><th>API key</th><th>Applications</th><th>Status</th></tr></thead>
107
+ ${region(tx('aisv.services'), html`<div class="table-wrap"><table class="report report-reflow">
108
+ <thead><tr><th>${tx('aisv.service')}</th><th>${tx('aisv.provider')}</th><th>${tx('aisv.model')}</th><th>${tx('aisv.api_key')}</th><th>${tx('aisv.applications')}</th><th>${tx('aisv.status')}</th></tr></thead>
108
109
  <tbody>${services.length ? services.map((x) => html`<tr>
109
- <td data-label="Service"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
110
- <td data-label="Provider">${providerLabel(x.provider)}</td>
111
- <td data-label="Model">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
112
- <td data-label="API key">${KEY_TEXT[keySource(x)]}</td>
113
- <td data-label="Applications">${x.apps ?? html`<span class="muted">none</span>`}</td>
114
- <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
115
- : html`<tr><td colspan="6" class="empty">No AI services yet.</td></tr>`}</tbody></table></div>`)}
116
- ${region('Add service', serviceForm({}, `${BASE}/ai`, csrf(s), true))}
110
+ <td data-label="${tx('aisv.service')}"><a href="${BASE}/ai/${x.id}">${x.name}</a>${x.description ? html` <span class="muted">${x.description}</span>` : ''}</td>
111
+ <td data-label="${tx('aisv.provider')}">${providerLabel(x.provider)}</td>
112
+ <td data-label="${tx('aisv.model')}">${x.model}${x.effort ? html` <span class="muted">(${x.effort})</span>` : ''}</td>
113
+ <td data-label="${tx('aisv.api_key')}">${KEY_TEXT[keySource(x)]}</td>
114
+ <td data-label="${tx('aisv.applications')}">${x.apps ?? html`<span class="muted">none</span>`}</td>
115
+ <td data-label="${tx('aisv.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
116
+ : html`<tr><td colspan="6" class="empty">${tx('aisv.no_ai_services_yet')}</td></tr>`}</tbody></table></div>`)}
117
+ ${region(tx('aisv.add_service'), serviceForm({}, `${BASE}/ai`, csrf(s), true))}
117
118
  </div>
118
- ${region('Usage (30 days)', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Service</th><th class="num">Requests</th><th class="num">Input tokens</th><th class="num">Output tokens</th><th class="num">Not ok</th></tr></thead>
119
- <tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="Application">${r.app ?? html`<span class="muted">builder tests</span>`}</td><td data-label="Service">${r.service}</td><td class="num" data-label="Requests">${r.requests}</td>
120
- <td class="num" data-label="Input tokens">${r.input}</td><td class="num" data-label="Output tokens">${r.output}</td><td class="num" data-label="Not ok">${r.problems}</td></tr>`)
121
- : html`<tr><td colspan="6" class="empty">No AI requests in the last 30 days.</td></tr>`}</tbody></table></div>`)}
122
- ${region('Recent requests', html`<p class="muted u-mt0">Tokens, model, duration and status of each call. Prompts and answers are never logged (an application's debug messages at level 9 show them).</p>${usageTable(recent, true)}`)}`;
123
- return send(reply, s, shell(s, 'AI services', [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['AI services']], main));
119
+ ${region(tx('aisv.usage_30_days'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.application')}</th><th>${tx('aisv.service')}</th><th class="num">${tx('aisv.requests')}</th><th class="num">${tx('aisv.input_tokens')}</th><th class="num">${tx('aisv.output_tokens')}</th><th class="num">${tx('aisv.not_ok')}</th></tr></thead>
120
+ <tbody>${summary.length ? summary.map((r) => html`<tr><td data-label="${tx('aisv.application')}">${r.app ?? html`<span class="muted">${tx('aisv.builder_tests')}</span>`}</td><td data-label="${tx('aisv.service')}">${r.service}</td><td class="num" data-label="${tx('aisv.requests')}">${r.requests}</td>
121
+ <td class="num" data-label="${tx('aisv.input_tokens')}">${r.input}</td><td class="num" data-label="${tx('aisv.output_tokens')}">${r.output}</td><td class="num" data-label="${tx('aisv.not_ok')}">${r.problems}</td></tr>`)
122
+ : html`<tr><td colspan="6" class="empty">${tx('aisv.no_ai_requests_in_the')}</td></tr>`}</tbody></table></div>`)}
123
+ ${region(tx('aisv.recent_requests'), html`<p class="muted u-mt0">${tx('aisv.tokens_model_duration_and_status')}</p>${usageTable(recent, true)}`)}`;
124
+ return send(reply, s, shell(s, tx('aisv.ai_services'), [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [tx('aisv.ai_services')]], main));
124
125
  });
125
126
 
126
127
  app.post(`${BASE}/ai`, async (req: Req, reply) => {
@@ -129,10 +130,10 @@ export async function aiRoutes(app: FastifyInstance) {
129
130
  const b = req.body ?? {};
130
131
  try {
131
132
  const name = (b.name ?? '').trim().toUpperCase();
132
- if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error('The name is upper case letters, digits and _ (e.g. CLAUDE).');
133
+ if (!/^[A-Z][A-Z0-9_]{0,59}$/.test(name)) throw new Error(tx('aisv.the_name_is_upper_case'));
133
134
  const key = b.api_key ? encryptSecret(b.api_key) : null;
134
135
  const r = await owner.one(`insert into meta.ai_service (name, ${COLUMNS}, api_key_enc) values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) returning id`, [name, ...formValues(b), key]);
135
- flash(s, 'AI service added. Allow applications to use it below, and test it.');
136
+ flash(s, tx('aisv.ai_service_added_allow_applications'));
136
137
  return back(reply, s, `${BASE}/ai/${r.id}`);
137
138
  } catch (e) {
138
139
  flash(s, (e as Error).message.includes('ai_service_name_key') ? 'A service with this name exists already.' : (e as Error).message, 'error');
@@ -163,33 +164,33 @@ export async function aiRoutes(app: FastifyInstance) {
163
164
  const main = html`
164
165
  <div class="title-row"><h1 class="ai-name">${x.name}</h1></div>
165
166
  <div class="columns wide-left">
166
- ${region('Settings', html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
167
+ ${region(tx('aisv.settings'), html`${serviceForm(view, `${BASE}/ai/${x.id}`, csrf(s), false)}
167
168
  <form method="post" action="${BASE}/ai/${x.id}/delete" class="danger-zone">${csrf(s)}
168
- <button class="btn btn-danger" data-confirm="Delete AI service ${x.name}? Processes that use it fail until another service gets its name.">Delete service</button></form>`)}
169
- ${region('Remote server', html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
170
- <p class="muted u-mt0">Instead of the base URL: a remote server's URL (Workspace utilities → Remote servers) plus a path, so moving the endpoint changes one value. The API key is sent there: choose a server whose workspace administrators you trust with it.</p>
171
- <div class="form-grid">${select('server_id', 'Remote server', x.server_id ? String(x.server_id) : '', [['', 'none: the base URL above'], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
172
- ${input('server_path', 'Path', x.server_path ?? '', { placeholder: '/v1', help: 'After the server\'s base URL, e.g. /v1; empty: the base URL itself.' })}</div>
173
- <div class="buttons"><button class="btn">Save</button></div></form>`)}
174
- ${region('Test', html`
175
- <p class="muted u-mt0">Sends a prompt to the service (counted in the usage log as a builder test).</p>
169
+ <button class="btn btn-danger" data-confirm="${tx('aisv.delete_confirm', { name: x.name })}">${tx('aisv.delete_service')}</button></form>`)}
170
+ ${region(tx('aisv.remote_server'), html`<form method="post" action="${BASE}/ai/${x.id}/server">${csrf(s)}
171
+ <p class="muted u-mt0">${tx('aisv.instead_of_the_base_url')}</p>
172
+ <div class="form-grid">${select('server_id', tx('aisv.remote_server'), x.server_id ? String(x.server_id) : '', [['', tx('aisv.none_the_base_url_above')], ...servers.map((r): [string, string] => [String(r.id), `${r.static_id} (${r.workspace})`])])}
173
+ ${input('server_path', tx('aisv.path'), x.server_path ?? '', { placeholder: '/v1', help: tx('aisv.after_the_server_s_base') })}</div>
174
+ <div class="buttons"><button class="btn">${tx('aisv.save')}</button></div></form>`)}
175
+ ${region(tx('aisv.test'), html`
176
+ <p class="muted u-mt0">${tx('aisv.sends_a_prompt_to_the')}</p>
176
177
  <form method="post" action="${BASE}/ai/${x.id}/test">${csrf(s)}
177
- <div class="field"><label class="label" for="ai_test_prompt">Prompt</label><textarea id="ai_test_prompt" name="prompt" rows="3" maxlength="4000">Reply with one short sentence to confirm you can read this.</textarea></div>
178
- <div class="buttons"><button class="btn">${icon('play')} Test</button></div></form>
178
+ <div class="field"><label class="label" for="ai_test_prompt">${tx('aisv.prompt')}</label><textarea id="ai_test_prompt" name="prompt" rows="3" maxlength="4000">${tx('aisv.test_prompt')}</textarea></div>
179
+ <div class="buttons"><button class="btn">${icon('play')} ${tx('aisv.test')}</button></div></form>
179
180
  ${test ? html`<div class="alert ${test.ok ? 'alert-success' : 'alert-error'}" role="status">${test.meta ?? ''}</div><pre class="source">${test.text}</pre>` : ''}`)}
180
181
  </div>
181
- ${region('Applications', html`<p class="muted u-mt0">Which applications may use this service, with optional limits per day (UTC). Empty limits: no limit. Requests over a limit fail with a message and are logged as <i>limited</i>.</p>
182
+ ${region(tx('aisv.applications'), html`<p class="muted u-mt0">${tx.rich('aisv.apps_intro', { limited: html`<i>${tx('aisv.limited')}</i>` })}</p>
182
183
  <form method="post" action="${BASE}/ai/${x.id}/apps">${csrf(s)}
183
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>May use</th><th>Requests per day</th><th>Tokens per day</th></tr></thead>
184
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.application')}</th><th>${tx('aisv.may_use')}</th><th>${tx('aisv.requests_per_day')}</th><th>${tx('aisv.tokens_per_day')}</th></tr></thead>
184
185
  <tbody>${apps.length ? apps.map((a) => html`<tr>
185
- <td data-label="Application">${a.name} <span class="muted">${a.alias}</span></td>
186
- <td data-label="May use"><label class="check"><input type="checkbox" name="allow" value="${a.id}"${a.allowed ? raw(' checked') : ''}> allowed</label></td>
187
- <td data-label="Requests per day"><input type="number" min="0" name="req_${a.id}" value="${a.max_requests ?? ''}" aria-label="Requests per day for ${a.alias}"></td>
188
- <td data-label="Tokens per day"><input type="number" min="0" name="tok_${a.id}" value="${a.max_tokens ?? ''}" aria-label="Tokens per day for ${a.alias}"></td></tr>`)
189
- : html`<tr><td colspan="4" class="empty">No applications.</td></tr>`}</tbody></table></div>
190
- <div class="buttons"><button class="btn btn-hot">Save access</button></div></form>`)}
191
- ${region('Recent requests', usageTable(recent, true))}`;
192
- return send(reply, s, shell(s, x.name, [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], ['AI services', `${BASE}/ai`], [x.name]], main));
186
+ <td data-label="${tx('aisv.application')}">${a.name} <span class="muted">${a.alias}</span></td>
187
+ <td data-label="${tx('aisv.may_use')}"><label class="check"><input type="checkbox" name="allow" value="${a.id}"${a.allowed ? raw(' checked') : ''}> allowed</label></td>
188
+ <td data-label="${tx('aisv.requests_per_day')}"><input type="number" min="0" name="req_${a.id}" value="${a.max_requests ?? ''}" aria-label="${tx('aisv.aria_req', { app: a.alias })}"></td>
189
+ <td data-label="${tx('aisv.tokens_per_day')}"><input type="number" min="0" name="tok_${a.id}" value="${a.max_tokens ?? ''}" aria-label="${tx('aisv.aria_tok', { app: a.alias })}"></td></tr>`)
190
+ : html`<tr><td colspan="4" class="empty">${tx('aisv.no_applications')}</td></tr>`}</tbody></table></div>
191
+ <div class="buttons"><button class="btn btn-hot">${tx('aisv.save_access')}</button></div></form>`)}
192
+ ${region(tx('aisv.recent_requests'), usageTable(recent, true))}`;
193
+ return send(reply, s, shell(s, x.name, [[tx('aisv.app_builder'), BASE], [tx('aisv.workspace_utilities'), `${BASE}/utilities`], [tx('aisv.ai_services'), `${BASE}/ai`], [x.name]], main));
193
194
  });
194
195
 
195
196
  app.post(`${BASE}/ai/:id(^\\d+$)`, async (req: Req, reply) => {
@@ -204,7 +205,7 @@ export async function aiRoutes(app: FastifyInstance) {
204
205
  where id = $1`,
205
206
  [req.params.id, ...formValues(b, !!(await owner.one('select 1 as ok from meta.ai_service where id = $1 and server_id is not null', [req.params.id]))), key, b.remove_key === 'true'],
206
207
  );
207
- flash(s, r.rowCount ? 'AI service saved.' : 'Not found.', r.rowCount ? 'ok' : 'error');
208
+ flash(s, r.rowCount ? 'AI service saved.' : tx('aisv.not_found'), r.rowCount ? 'ok' : 'error');
208
209
  } catch (e) {
209
210
  flash(s, (e as Error).message, 'error');
210
211
  }
@@ -219,12 +220,12 @@ export async function aiRoutes(app: FastifyInstance) {
219
220
  try {
220
221
  const serverId = /^\d{1,9}$/.test(String(b.server_id ?? '')) ? Number(b.server_id) : null;
221
222
  const path = String(b.server_path ?? '').trim() || null;
222
- if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error('The path starts with /, without spaces, e.g. /v1.');
223
- if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error('No such remote server.');
223
+ if (path && (!/^\/[^\s]*$/.test(path) || path.length > 500)) throw new Error(tx('aisv.the_path_starts_with_without'));
224
+ if (serverId && !(await owner.one('select 1 as ok from meta.remote_server where id = $1', [serverId]))) throw new Error(tx('aisv.no_such_remote_server'));
224
225
  const r = await owner.query('update meta.ai_service set server_id = $2, server_path = $3, updated_at = now() where id = $1', [req.params.id, serverId, serverId ? path : null]);
225
- flash(s, r.rowCount ? (serverId ? 'The service now calls its remote server: test it.' : 'The service calls its base URL again.') : 'Not found.', r.rowCount ? 'ok' : 'error');
226
+ flash(s, r.rowCount ? (serverId ? tx('aisv.the_service_now_calls_its') : tx('aisv.the_service_calls_its_base')) : tx('aisv.not_found'), r.rowCount ? 'ok' : 'error');
226
227
  } catch (e) {
227
- flash(s, (e as Error).message.includes('ai_service_compatible_url') ? 'An OpenAI-compatible service needs a base URL or a remote server.' : (e as Error).message, 'error');
228
+ flash(s, (e as Error).message.includes('ai_service_compatible_url') ? tx('aisv.an_openai_compatible_service_needs') : (e as Error).message, 'error');
228
229
  }
229
230
  return back(reply, s, `${BASE}/ai/${req.params.id}`);
230
231
  });
@@ -238,7 +239,7 @@ export async function aiRoutes(app: FastifyInstance) {
238
239
  const t = String(v ?? '').trim();
239
240
  if (!t) return null;
240
241
  const n = Number(t);
241
- if (!Number.isInteger(n) || n < 0) throw new Error('Limits are whole numbers of 0 or more (empty: no limit).');
242
+ if (!Number.isInteger(n) || n < 0) throw new Error(tx('aisv.limits_are_whole_numbers_of'));
242
243
  return n;
243
244
  };
244
245
  try {
@@ -252,7 +253,7 @@ export async function aiRoutes(app: FastifyInstance) {
252
253
  [id, req.params.id, limit(b[`req_${id}`]), limit(b[`tok_${id}`])],
253
254
  );
254
255
  });
255
- flash(s, 'Access saved.');
256
+ flash(s, tx('aisv.access_saved'));
256
257
  } catch (e) {
257
258
  flash(s, (e as Error).message, 'error');
258
259
  }
@@ -269,9 +270,9 @@ export async function aiRoutes(app: FastifyInstance) {
269
270
  const prompt = String(req.body?.prompt ?? '').slice(0, 4000) || 'Reply with one short sentence.';
270
271
  const r = await generate(x.name, { prompt, maxTokens: 1000 }, { appId: null, user: s.username, source: 'builder' });
271
272
  const text = r.text.length > 4000 ? `${r.text.slice(0, 4000)}…` : r.text;
272
- result = { id: x.id, ok: true, text, meta: `Answered by ${r.model}: ${r.inputTokens} input and ${r.outputTokens} output tokens.` };
273
+ result = { id: x.id, ok: true, text, meta: tx('aisv.answered_by', { model: r.model, input: r.inputTokens, output: r.outputTokens }) };
273
274
  } catch (e) {
274
- result = { id: x.id, ok: false, text: '', meta: e instanceof AiError ? e.message : 'The test failed.' };
275
+ result = { id: x.id, ok: false, text: '', meta: e instanceof AiError ? e.message : tx('aisv.the_test_failed') };
275
276
  }
276
277
  s.state[TEST_RESULT] = JSON.stringify(result);
277
278
  return back(reply, s, `${BASE}/ai/${x.id}`);
@@ -281,7 +282,7 @@ export async function aiRoutes(app: FastifyInstance) {
281
282
  const s = await developer(req, reply);
282
283
  if (!s || !(await adminOnly(s, reply, 'AI services'))) return;
283
284
  await owner.query('delete from meta.ai_service where id = $1', [req.params.id]);
284
- flash(s, 'AI service deleted.');
285
+ flash(s, tx('aisv.ai_service_deleted'));
285
286
  return back(reply, s, `${BASE}/ai`);
286
287
  });
287
288
 
@@ -298,15 +299,15 @@ export async function aiRoutes(app: FastifyInstance) {
298
299
  const admin = await isAdmin(s.username);
299
300
  const main = html`
300
301
  <div class="title-row"><h1 class="ai-name">AI usage · ${a.name}</h1></div>
301
- <p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai">AI services</a>` : ''}), today's use against their limits, and the recent requests.</p>
302
- ${region('Services', html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Service</th><th>Model</th><th class="num">Requests today</th><th class="num">Tokens today</th><th>Status</th></tr></thead>
303
- <tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="Service">${x.name}</td><td data-label="Model">${providerLabel(x.provider)} ${x.model}</td>
304
- <td class="num" data-label="Requests today">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
305
- <td class="num" data-label="Tokens today">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
306
- <td data-label="Status">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
307
- : html`<tr><td colspan="5" class="empty">This application may not use any AI service yet.</td></tr>`}</tbody></table></div>`)}
308
- ${region('Recent requests', usageTable(recent, false))}
302
+ <p class="muted u-mt0">The AI services this application may use (an administrator allows them${admin ? html` under <a href="${BASE}/ai">${tx('aisv.ai_services')}</a>` : ''}), today's use against their limits, and the recent requests.</p>
303
+ ${region(tx('aisv.services'), html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('aisv.service')}</th><th>${tx('aisv.model')}</th><th class="num">${tx('aisv.requests_today')}</th><th class="num">${tx('aisv.tokens_today')}</th><th>${tx('aisv.status')}</th></tr></thead>
304
+ <tbody>${allowed.length ? allowed.map((x, i) => html`<tr><td data-label="${tx('aisv.service')}">${x.name}</td><td data-label="${tx('aisv.model')}">${providerLabel(x.provider)} ${x.model}</td>
305
+ <td class="num" data-label="${tx('aisv.requests_today')}">${today[i].requests}${x.max_requests !== null ? ` / ${x.max_requests}` : ''}</td>
306
+ <td class="num" data-label="${tx('aisv.tokens_today')}">${today[i].tokens}${x.max_tokens !== null ? ` / ${x.max_tokens}` : ''}</td>
307
+ <td data-label="${tx('aisv.status')}">${x.enabled ? 'enabled' : html`<b>disabled</b>`}</td></tr>`)
308
+ : html`<tr><td colspan="5" class="empty">${tx('aisv.this_application_may_not_use')}</td></tr>`}</tbody></table></div>`)}
309
+ ${region(tx('aisv.recent_requests'), usageTable(recent, false))}
309
310
  ${await toolCallTable(a.id)}`;
310
- return send(reply, s, shell(s, `AI usage · ${a.name}`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Activity', `${BASE}/apps/${a.id}/activity`], ['AI usage']], main));
311
+ return send(reply, s, shell(s, `AI usage · ${a.name}`, [[tx('aisv.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('aisv.activity'), `${BASE}/apps/${a.id}/activity`], [tx('aisv.ai_usage')]], main));
311
312
  });
312
313
  }
@@ -7,6 +7,7 @@ import { publicUrl } from '../sso.ts';
7
7
  import { icon } from '../icons.ts';
8
8
  import { appHeader, back, BASE, csrf, developer, flash, input, region, send, shell, type Req } from './ui.ts';
9
9
  import { roleHints, roleHintsHtml, splitRoles } from './users.ts';
10
+ import { scopedTx as tx } from './i18n.ts';
10
11
 
11
12
  // Per-application REST API page: the database role API tokens use, what
12
13
  // PostgREST exposes to it, and developer-issued tokens for trying it out.
@@ -58,43 +59,42 @@ async function clientsRegion(s: Session, a: any, secret?: NewSecret, usable = tr
58
59
  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
60
  const createForm = html`<form method="post" action="${BASE}/apps/${a.id}/api/clients">${csrf(s)}
60
61
  <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' })}
62
+ ${input('name', tx('apip.name'), '', { required: true, placeholder: tx('apip.e_g_payroll_sync'), help: tx('apip.lower_case_letters_digits') })}
63
+ ${input('token_minutes', tx('apip.token_lifetime_minutes'), 60, { type: 'number', help: tx('apip.5_to_1440') })}
64
+ <div class="field"><label class="label" for="f_roles">${tx('apip.roles')}</label><input id="f_roles" name="roles" placeholder="${tx('apip.comma_separated_or_pick_below')}">${roleHintsHtml(hints)}</div>
65
+ ${input('description', tx('apip.description'), '', { placeholder: tx('apip.who_uses_it') })}
65
66
  </div>
66
- <div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>Create client</button></div>
67
+ <div class="buttons"><button class="btn btn-hot"${usable ? '' : raw(' disabled')}>${tx('apip.create_client')}</button></div>
67
68
  </form>`;
68
69
  return region(
69
70
  'OAuth clients',
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>
71
+ html`<p class="muted u-mt0">${tx.rich('apip.oauth_intro', { ords: html`<code>oauth.create_client</code>`, url: html`<code>${tokenUrl}</code>`, grant: html`<code>grant_type=client_credentials</code>`, client: html`<code>client:&lt;name&gt;</code>` })}</p>
72
72
  ${secret
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.${
74
- secret.rotated && secret.graceHours ? ` The previous secret keeps working for ${secret.graceHours} hours.` : ''}</div>
73
+ ? html`<div class="alert alert-success" role="status">${secret.rotated ? tx('apip.new_secret_for', { name: secret.name }) : tx('apip.client_created', { name: secret.name })}. ${tx('apip.copy_now')}${
74
+ secret.rotated && secret.graceHours ? ` ${tx('apip.grace', { n: secret.graceHours })}` : ''}</div>
75
75
  <div class="form-grid">
76
- <div class="field"><label class="label" for="oauth_id">Client ID</label><input id="oauth_id" class="code" readonly value="${secret.clientId}"></div>
77
- <div class="field"><label class="label" for="oauth_secret">Client secret</label><input id="oauth_secret" class="code" readonly value="${secret.secret}"></div>
76
+ <div class="field"><label class="label" for="oauth_id">${tx('apip.client_id')}</label><input id="oauth_id" class="code" readonly value="${secret.clientId}"></div>
77
+ <div class="field"><label class="label" for="oauth_secret">${tx('apip.client_secret')}</label><input id="oauth_secret" class="code" readonly value="${secret.secret}"></div>
78
78
  </div>
79
79
  <pre class="source">curl -u '${secret.clientId}:${secret.secret}' -d grant_type=client_credentials ${tokenUrl}</pre>`
80
80
  : ''}
81
81
  ${clients.length
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>
82
+ ? html`<div class="table-wrap"><table class="report report-reflow api-clients"><thead><tr><th>${tx('apip.client')}</th><th>${tx('apip.roles')}</th><th class="num">${tx('apip.token')}</th><th>${tx('apip.secret_changed')}</th><th>${tx('apip.last_used')}</th><th><span class="sr-only">${tx('apip.actions')}</span></th></tr></thead><tbody>
83
83
  ${clients.map((c) => html`<tr${c.active ? '' : raw(' class="muted"')}>
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>
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)}
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>
89
- <td data-label="Last used">${when(c.last_used_at) || html`<span class="muted">never</span>`}</td>
84
+ <td data-label="${tx('apip.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>
85
+ <td data-label="${tx('apip.roles')}"><form method="post" action="${BASE}/apps/${a.id}/api/clients/${c.id}/roles" class="search roles-form u-m0 u-mwnone">${csrf(s)}
86
+ <input name="roles" value="${c.roles.join(', ')}" aria-label="${tx('apip.aria_roles', { name: c.name })}" placeholder="${tx('apip.no_roles')}"><button class="btn btn-sm">${tx('apip.save')}</button>${roleHintsHtml(hints, tx('apip.add'))}</form></td>
87
+ <td class="num" data-label="${tx('apip.token')}">${tx('apip.minutes', { n: c.token_minutes })}</td>
88
+ <td data-label="${tx('apip.secret_changed')}">${when(c.secret_changed_at)}${c.grace ? html`<br><small class="help">${tx('apip.old_valid_until', { when: when(c.previous_valid_until) })}</small>` : ''}</td>
89
+ <td data-label="${tx('apip.last_used')}">${when(c.last_used_at) || html`<span class="muted">${tx('apip.never')}</span>`}</td>
90
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')}
91
+ ${action(c, 'rotate', tx('apip.new_secret'), html`<select name="grace" aria-label="${tx('apip.keep_the_old_secret')}"><option value="24">${tx('apip.old_24h')}</option><option value="168">${tx('apip.old_7d')}</option><option value="0">${tx('apip.old_now')}</option></select>`, 'btn btn-sm')}
92
+ ${action(c, 'active', c.active ? tx('apip.revoke') : tx('apip.reactivate'), html`<input type="hidden" name="active" value="${c.active ? 'false' : 'true'}">`, 'btn btn-sm')}
93
+ ${action(c, 'delete', tx('apip.delete'), '', 'btn btn-sm btn-danger')}
94
94
  </div></td></tr>`)}
95
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}`}`,
96
+ <details class="api-new u-mt1"><summary class="btn">${icon('plus')} ${tx('apip.new_client')}</summary><div class="api-new-body">${createForm}</div></details>`
97
+ : html`<p class="muted">${tx('apip.no_clients_yet')}</p>${createForm}`}`,
98
98
  html`<span class="count">${clients.length}</span>`,
99
99
  );
100
100
  }
@@ -117,42 +117,42 @@ async function apiPage(s: Session, a: any, issued?: { token: string; username: s
117
117
  `curl -X POST ${url}/rpc/${e.name} -H "Authorization: Bearer ${token}" -H "Content-Type: application/json" -d '{ … }'`),
118
118
  ].join('\n');
119
119
 
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>`;
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 ? tx('apip.ok') : tx('apip.problem')}</span>${text}</li>`;
121
121
  const main = html`${appHeader(a, 'api', s)}
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>`) : ''}
122
+ <p class="muted api-intro">${tx.rich('apip.intro', { schema: html`<code>${API_SCHEMA}</code>`, role: html`<code>role</code>`, user: html`<code>meta.app_user()</code>`, has: html`<code>meta.has_role()</code>` })}</p>
123
+ <ul class="api-status" aria-label="${tx('apip.status')}">
124
+ ${pill(status.ok, html`${tx.rich('apip.at', { url: html`<code>${url}</code>` })} ${status.ok ? status.detail : html`<b>${tx('apip.not_reachable')}</b> (${status.detail})`}`)}
125
+ ${role ? pill(!problem, problem ?? tx.rich('apip.dedicated', { role: html`<code>${role}</code>` })) : pill(false, tx('apip.no_role'))}
126
+ ${role && !problem ? pill(!!granted?.ok, granted?.ok ? tx.rich('apip.may_switch', { role: html`<code>${role}</code>` }) : tx.rich('apip.run_grant', { run: html`<b>${tx('apip.run')}</b>`, sql: html`<code>grant ${role} to pgkiln_authenticator;</code>` })) : ''}
127
127
  </ul>
128
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>
133
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
129
+ ${region(tx('apip.setup'), html`<form method="post" action="${BASE}/apps/${a.id}/api">${csrf(s)}
130
+ ${input('api_role', tx('apip.api_database_role'), role, { placeholder: tx('apip.e_g_myapp_api'), help: tx('apip.grant_it_only_the_api') })}
131
+ <div class="field"><label class="check"><input type="checkbox" name="api_self_tokens" value="true"${a.api_self_tokens ? raw(' checked') : ''}> ${tx('apip.users_create_their_own_tokens')}</label>
132
+ <small class="help">${tx.rich('apip.self_help', { account: html`<em>${tx('apip.my_account')}</em>` })}</small></div>
133
+ <div class="buttons"><button class="btn btn-hot">${tx('apip.save')}</button></div>
134
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>
135
+ ${region(tx('apip.issue_a_token'), html`<p class="muted u-mt0">${tx.code('apip.for_development_and_trusted_scripts')}</p>
136
136
  <form method="post" action="${BASE}/apps/${a.id}/api/token">${csrf(s)}
137
137
  <div class="form-grid">
138
- ${input('username', 'Account', issued?.username ?? '', { required: true, auto: 'off' })}
139
- ${input('hours', 'Valid for (hours)', issued?.expiresInHours ?? 8, { type: 'number', help: `1 to ${MAX_TOKEN_HOURS}.` })}
138
+ ${input('username', tx('apip.account'), issued?.username ?? '', { required: true, auto: 'off' })}
139
+ ${input('hours', tx('apip.valid_for_hours'), issued?.expiresInHours ?? 8, { type: 'number', help: `1 to ${MAX_TOKEN_HOURS}.` })}
140
140
  </div>
141
- <div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}>Issue token</button></div>
141
+ <div class="buttons"><button class="btn btn-hot"${!problem ? '' : raw(' disabled')}>${tx('apip.issue_token')}</button></div>
142
142
  </form>
143
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>
144
144
  <textarea id="api_token" class="code" rows="4" readonly spellcheck="false">${issued.token}</textarea></div>` : ''}`)}
145
145
  </div>
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>
146
+ ${region(tx('apip.endpoints'), html`${eps.length
147
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('apip.path')}</th><th>${tx('apip.kind')}</th><th>${tx('apip.methods_for', { role: role ?? '' })}</th></tr></thead>
148
+ <tbody>${eps.map((e) => html`<tr><td data-label="${tx('apip.path')}"><code>/${e.kind === 'function' ? 'rpc/' : ''}${e.name}</code></td><td data-label="${tx('apip.kind')}">${e.kind}</td><td data-label="${tx('apip.methods')}">${e.methods || html`<span class="muted">${tx('apip.none')}</span>`}</td></tr>`)}</tbody></table></div>`
149
+ : html`<p class="muted u-mt0">${tx.rich(role ? 'apip.nothing_yet' : 'apip.nothing_yet_no_role', { schema: html`<code>${API_SCHEMA}</code>` })}</p>`}
150
+ <h3 class="api-sub">${tx('apip.try_it')}</h3>
151
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>`,
152
+ <p class="muted small">${tx.rich('apip.reload', { sql: html`<code>notify pgrst, 'reload schema';</code>`, link: html`<a href="${url}/" target="_blank" rel="noopener">${url}/</a>` })}</p>`,
153
153
  html`<span class="count">${eps.length}</span>`)}
154
154
  ${await clientsRegion(s, a, secret, !problem)}`;
155
- return shell(s, `${a.name} REST API`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['REST API']], main);
155
+ return shell(s, `${a.name} REST API`, [[tx('apip.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('apip.rest_api')]], main);
156
156
  }
157
157
 
158
158
  export async function apiRoutes(app: FastifyInstance) {
@@ -214,7 +214,7 @@ export async function apiRoutes(app: FastifyInstance) {
214
214
  return send(reply.header('cache-control', 'no-store'), s, await apiPage(s, a, undefined, { name, clientId: r.client_id, secret: r.client_secret, rotated: false }));
215
215
  } catch (e) {
216
216
  const err = e as { code?: string; message: string };
217
- flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? 'Check the name (lower case letters, digits, . _ -) and the token lifetime (5–1440 minutes).' : err.message, 'error');
217
+ flash(s, err.code === '23505' ? 'A client with this name already exists.' : err.code === '23514' ? tx('apip.check_the_name_lower_case') : err.message, 'error');
218
218
  return back(reply, s, `${BASE}/apps/${a.id}/api`);
219
219
  }
220
220
  });
@@ -246,7 +246,7 @@ export async function apiRoutes(app: FastifyInstance) {
246
246
  flash(s, active ? `${c.name} can get tokens again.` : `${c.name} is revoked: its tokens stop working now.`);
247
247
  } else if (op === 'roles') {
248
248
  await owner.query('update meta.api_client set roles = $2 where id = $1', [c.id, splitRoles(req.body?.roles ?? '')]);
249
- flash(s, `Roles of ${c.name} saved.`);
249
+ flash(s, tx('apip.roles_saved', { name: c.name }));
250
250
  } else if (op === 'delete') {
251
251
  await owner.query('delete from meta.api_client where id = $1', [c.id]);
252
252
  flash(s, `${c.name} deleted.`);