pgkiln 0.84.0 → 0.85.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/docs/apex-feature-parity.md +2 -2
  2. package/docs/guide/03-builder.md +14 -5
  3. package/docs/guide/12-development.md +6 -1
  4. package/package.json +1 -1
  5. package/src/app.ts +9 -0
  6. package/src/builder/ai-agents.ts +86 -85
  7. package/src/builder/ai-builder.ts +30 -30
  8. package/src/builder/ai.ts +93 -92
  9. package/src/builder/api.ts +47 -47
  10. package/src/builder/app-groups.ts +33 -32
  11. package/src/builder/appfromfile.ts +57 -58
  12. package/src/builder/appsheets.ts +33 -32
  13. package/src/builder/appwizard.ts +65 -65
  14. package/src/builder/arrange.ts +23 -22
  15. package/src/builder/assistant.ts +39 -38
  16. package/src/builder/automations.ts +32 -31
  17. package/src/builder/availability.ts +24 -24
  18. package/src/builder/backups.ts +30 -31
  19. package/src/builder/blueprints.ts +62 -61
  20. package/src/builder/create-page.ts +52 -50
  21. package/src/builder/cross-app.ts +37 -35
  22. package/src/builder/dataload.ts +2 -2
  23. package/src/builder/datasets.ts +107 -106
  24. package/src/builder/dependencies.ts +20 -19
  25. package/src/builder/designer.ts +36 -36
  26. package/src/builder/diagnostics.ts +58 -57
  27. package/src/builder/features.ts +39 -35
  28. package/src/builder/feedback.ts +23 -20
  29. package/src/builder/find-workspace.ts +28 -24
  30. package/src/builder/form-designer.ts +36 -35
  31. package/src/builder/gallery.ts +25 -29
  32. package/src/builder/globalization.ts +50 -49
  33. package/src/builder/i18n/de.ts +3453 -0
  34. package/src/builder/i18n/en.ts +3453 -0
  35. package/src/builder/i18n/es.ts +3453 -0
  36. package/src/builder/i18n/fr.ts +3453 -0
  37. package/src/builder/i18n/it.ts +3453 -0
  38. package/src/builder/i18n/ja.ts +3453 -0
  39. package/src/builder/i18n/ko.ts +3453 -0
  40. package/src/builder/i18n/nl.ts +3453 -0
  41. package/src/builder/i18n/pt.ts +3453 -0
  42. package/src/builder/i18n/zh-cn.ts +3453 -0
  43. package/src/builder/i18n/zh-tw.ts +3453 -0
  44. package/src/builder/i18n.ts +17 -3
  45. package/src/builder/json-sources.ts +63 -62
  46. package/src/builder/layouts.ts +12 -11
  47. package/src/builder/locks.ts +29 -28
  48. package/src/builder/login-page.ts +26 -25
  49. package/src/builder/mail.ts +148 -147
  50. package/src/builder/newapp.ts +15 -12
  51. package/src/builder/plugins.ts +47 -44
  52. package/src/builder/process-jobs.ts +11 -10
  53. package/src/builder/pwa.ts +22 -21
  54. package/src/builder/remote-databases.ts +55 -56
  55. package/src/builder/report-settings.ts +1 -1
  56. package/src/builder/reporter.ts +43 -42
  57. package/src/builder/rest-catalogs.ts +87 -86
  58. package/src/builder/rest.ts +9 -8
  59. package/src/builder/routes.ts +27 -27
  60. package/src/builder/search.ts +14 -13
  61. package/src/builder/static-files.ts +31 -32
  62. package/src/builder/subscriptions.ts +47 -45
  63. package/src/builder/supporting.ts +23 -18
  64. package/src/builder/team.ts +179 -179
  65. package/src/builder/telemetry.ts +59 -62
  66. package/src/builder/templates.ts +66 -65
  67. package/src/builder/themeroller.ts +60 -63
  68. package/src/builder/ui-defaults.ts +21 -20
  69. package/src/builder/ui.ts +2 -1
  70. package/src/builder/upgrade.ts +25 -24
  71. package/src/builder/utilities.ts +48 -47
  72. package/src/builder/vector-providers.ts +67 -66
  73. package/src/builder/websources.ts +43 -42
  74. package/src/builder/wizards.ts +95 -89
  75. package/src/builder/workflow-migrate.ts +30 -31
  76. package/src/builder/workflows.ts +40 -39
  77. package/src/builder/workingcopies.ts +41 -49
  78. package/src/builder/workspace-backups.ts +21 -20
  79. package/src/builder/workspace-mail.ts +17 -16
  80. package/src/builder/workspace-resources.ts +104 -112
  81. package/src/builder/workspace-transfer.ts +34 -26
@@ -12,6 +12,7 @@ import { scopeProblem, urlProblem } from '../webclient.ts';
12
12
  import { clearResponseCache, clearTokens, credentialProblems, GRANT_TYPES } from '../websources.ts';
13
13
  import { back, BASE, csrf, developer, flash, input, region, select, send, shell, type Body, type Req } from './ui.ts';
14
14
  import { workspaceAdmin } from './workspaces.ts';
15
+ import { scopedTx as tx } from './i18n.ts';
15
16
 
16
17
  // (sprint 141, AP-7) Workspace utilities → Static workspace files, Remote
17
18
  // servers and Web credentials (APEX: Static Workspace Files, Remote Servers,
@@ -28,7 +29,7 @@ import { workspaceAdmin } from './workspaces.ts';
28
29
  const FILES = `${BASE}/workspace-files`;
29
30
  const SERVERS = `${BASE}/remote-servers`;
30
31
  const CREDS = `${BASE}/workspace-credentials`;
31
- const crumbs = (title: string): [string, string?][] => [['App Builder', BASE], ['Workspace utilities', `${BASE}/utilities`], [title]];
32
+ const crumbs = (title: string): [string, string?][] => [[tx('wres.app_builder'), BASE], [tx('wres.workspace_utilities'), `${BASE}/utilities`], [title]];
32
33
  const id = (v: unknown) => (/^\d{1,9}$/.test(String(v ?? '')) ? Number(v) : null);
33
34
  const when = (d: Date | string | null) => (d ? new Date(d).toISOString().slice(0, 16).replace('T', ' ') : '');
34
35
  const size = (n: number) => (n < 1024 ? `${n} B` : n < 1024 * 1024 ? `${(n / 1024).toFixed(1)} KB` : `${(n / 1024 / 1024).toFixed(1)} MB`);
@@ -45,7 +46,7 @@ async function context(req: Req, reply: FastifyReply, title: string): Promise<Ct
45
46
  const s = await developer(req, reply);
46
47
  if (!s) return null;
47
48
  if (!s.workspace) {
48
- await send(reply.code(403), s, shell(s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">You are not a developer of any workspace yet.</div>`));
49
+ await send(reply.code(403), s, shell(s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('wres.you_are_not_a_developer')}</div>`));
49
50
  return null;
50
51
  }
51
52
  return { s, ws: { id: s.workspace.id, name: s.workspace.name }, admin: await workspaceAdmin(s.username, s.workspace.id) };
@@ -56,11 +57,11 @@ async function manager(req: Req, reply: FastifyReply, title: string): Promise<Ct
56
57
  const c = await context(req, reply, title);
57
58
  if (!c) return null;
58
59
  if (c.admin) return c;
59
- await send(reply.code(403), c.s, shell(c.s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">Only the workspace's administrators change these.</div>`));
60
+ await send(reply.code(403), c.s, shell(c.s, title, crumbs(title), html`<h1 class="u-mb1">${title}</h1><div class="alert alert-error" role="alert">${tx('wres.only_the_workspace_s_administrators')}</div>`));
60
61
  return null;
61
62
  }
62
63
 
63
- const readOnlyNote = (c: Ctx) => (c.admin ? '' : html`<div class="alert alert-info" role="status">You can see these; the workspace's administrators change them.</div>`);
64
+ const readOnlyNote = (c: Ctx) => (c.admin ? '' : html`<div class="alert alert-info" role="status">${tx('wres.you_can_see_these_the')}</div>`);
64
65
 
65
66
  // ====================================================================== static workspace files
66
67
 
@@ -89,40 +90,36 @@ async function filesPage(c: Ctx, query: Record<string, string | undefined>) {
89
90
  ? (await owner.one<{ content: Buffer }>('select content from meta.workspace_file where workspace_id = $1 and name = $2', [ws.id, editName]))!.content.toString('utf8')
90
91
  : '';
91
92
  const editor = editName || creating
92
- ? region(editName ? `Edit ${editName}` : 'New file', html`<form method="post" action="${FILES}/save">${csrf(s)}
93
+ ? region(editName ? tx('wres.edit_named', { name: editName }) : tx('wres.new_file'), html`<form method="post" action="${FILES}/save">${csrf(s)}
93
94
  ${editName ? html`<input type="hidden" name="original" value="${editName}">` : ''}
94
- <div class="form-grid">${input('name', 'File name', editName ?? '', { required: true, placeholder: 'brand.css', help: 'Letters, digits, _, - and . (e.g. brand.css, common.js).' })}</div>
95
- <div class="field" data-wide><label class="label" for="f_content">Content</label>
95
+ <div class="form-grid">${input('name', tx('wres.file_name'), editName ?? '', { required: true, placeholder: 'brand.css', help: tx('wres.letters_digits_and_e_g') })}</div>
96
+ <div class="field" data-wide><label class="label" for="f_content">${tx('wres.content')}</label>
96
97
  <textarea id="f_content" name="content" class="code" rows="18" spellcheck="false">${content}</textarea></div>
97
- <div class="buttons"><a class="btn" href="${FILES}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div>
98
+ <div class="buttons"><a class="btn" href="${FILES}">${tx('wres.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('wres.save')}</button></div>
98
99
  </form>`)
99
100
  : '';
100
- return html`<div class="title-row"><h1>Static workspace files</h1></div>
101
- <p class="muted u-mt0">CSS, JavaScript, JSON, images and fonts that every application of ${ws.name} may use: an application loads one on
102
- every page with <code>#WORKSPACE_FILES#<i>name</i></code> in Shared Components → Static application files → Every page loads (a page:
103
- Page → Appearance), and templates, HTML and links use <code>#WORKSPACE_FILES#</code> for their URL. Each application serves them from its
104
- own address (<code>/a/<i>alias</i>/workspace-files/<i>name</i></code>), never another workspace's. Up to ${size(STATIC_MAX)} per file; no HTML.
105
- They travel with the workspace export.</p>
101
+ return html`<div class="title-row"><h1>${tx('wres.static_workspace_files')}</h1></div>
102
+ <p class="muted u-mt0">${tx.rich('wres.files_intro', { workspace: ws.name, tag: html`<code>#WORKSPACE_FILES#<i>name</i></code>`, sub: html`<code>#WORKSPACE_FILES#</code>`, path: html`<code>/a/<i>alias</i>/workspace-files/<i>name</i></code>`, size: size(STATIC_MAX) })}</p>
106
103
  ${readOnlyNote(c)}
107
104
  ${editor}
108
- ${region('Files', html`${files.length
105
+ ${region(tx('wres.files'), html`${files.length
109
106
  ? html`<div class="table-wrap"><table class="report report-reflow">
110
- <thead><tr><th scope="col">Name</th><th scope="col">Type</th><th scope="col" class="num">Size</th><th scope="col">Loaded by</th><th scope="col">Changed (UTC)</th>${c.admin ? html`<th scope="col">Actions</th>` : ''}</tr></thead>
107
+ <thead><tr><th scope="col">${tx('wres.name')}</th><th scope="col">${tx('wres.type')}</th><th scope="col" class="num">${tx('wres.size')}</th><th scope="col">${tx('wres.loaded_by')}</th><th scope="col">${tx('wres.changed_utc')}</th>${c.admin ? html`<th scope="col">${tx('wres.actions')}</th>` : ''}</tr></thead>
111
108
  <tbody>${files.map((f) => html`<tr>
112
- <td data-label="Name" class="u-break"><a href="${FILES}/file/${encodeURIComponent(f.name)}" target="_blank" rel="noopener">${f.name}</a></td>
113
- <td data-label="Type">${f.mime}</td><td class="num" data-label="Size">${size(Number(f.bytes))}</td>
114
- <td data-label="Loaded by" class="u-break">${includable(f.name) ? (users.get(f.name)?.join(', ') || html`<span class="muted">-</span>`) : ''}</td>
115
- <td data-label="Changed">${when(f.changed)}${f.updated_by ? html` <span class="muted">${f.updated_by}</span>` : ''}</td>
116
- ${c.admin ? html`<td data-label="Actions" class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${FILES}?edit=${encodeURIComponent(f.name)}">Edit</a> ` : ''}
109
+ <td data-label="${tx('wres.name')}" class="u-break"><a href="${FILES}/file/${encodeURIComponent(f.name)}" target="_blank" rel="noopener">${f.name}</a></td>
110
+ <td data-label="${tx('wres.type')}">${f.mime}</td><td class="num" data-label="${tx('wres.size')}">${size(Number(f.bytes))}</td>
111
+ <td data-label="${tx('wres.loaded_by')}" class="u-break">${includable(f.name) ? (users.get(f.name)?.join(', ') || html`<span class="muted">-</span>`) : ''}</td>
112
+ <td data-label="${tx('wres.changed')}">${when(f.changed)}${f.updated_by ? html` <span class="muted">${f.updated_by}</span>` : ''}</td>
113
+ ${c.admin ? html`<td data-label="${tx('wres.actions')}" class="nowrap">${editable(f.name) ? html`<a class="btn btn-small" href="${FILES}?edit=${encodeURIComponent(f.name)}">${tx('wres.edit')}</a> ` : ''}
117
114
  <form method="post" action="${FILES}/delete" class="inline">${csrf(s)}<input type="hidden" name="name" value="${f.name}">
118
- <button class="btn btn-small btn-danger" data-confirm="Delete ${f.name}? Applications that load it leave it out." aria-label="Delete ${f.name}">${icon('trash')}</button></form></td>` : ''}
115
+ <button class="btn btn-small btn-danger" data-confirm="${tx('wres.delete_file_confirm', { name: f.name })}" aria-label="${tx('wres.delete_named', { name: f.name })}">${icon('trash')}</button></form></td>` : ''}
119
116
  </tr>`)}</tbody></table></div>`
120
- : html`<p class="muted">No files yet.</p>`}
121
- ${c.admin ? html`<div class="buttons u-mt1"><a class="btn" href="${FILES}?new=1">${icon('plus')} New file</a></div>
117
+ : html`<p class="muted">${tx('wres.no_files_yet')}</p>`}
118
+ ${c.admin ? html`<div class="buttons u-mt1"><a class="btn" href="${FILES}?new=1">${icon('plus')} ${tx('wres.new_file')}</a></div>
122
119
  <form method="post" action="${FILES}/upload" enctype="multipart/form-data" class="search u-mwnone u-mt1">${csrf(s)}
123
- <input type="file" name="file" multiple aria-label="Files to upload" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
124
- <button class="btn">${icon('upload')} Upload</button>
125
- <small class="help">A file with the same name is replaced.</small>
120
+ <input type="file" name="file" multiple aria-label="${tx('wres.files_to_upload')}" accept="${Object.keys(STATIC_TYPES).map((x) => `.${x}`).join(',')}">
121
+ <button class="btn">${icon('upload')} ${tx('wres.upload')}</button>
122
+ <small class="help">${tx('wres.a_file_with_the_same')}</small>
126
123
  </form>` : ''}`)}`;
127
124
  }
128
125
 
@@ -170,44 +167,41 @@ async function serversPage(c: Ctx, query: Record<string, string | undefined>) {
170
167
  try {
171
168
  url = new URL(u.url);
172
169
  } catch {
173
- return html`<span class="tag tag-error">not a valid URL</span>`;
170
+ return html`<span class="tag tag-error">${tx('wres.not_a_valid_url')}</span>`;
174
171
  }
175
172
  const problem = urlProblem(url) ?? scopeProblem(scope, url);
176
- return problem ? html`<span class="tag tag-error">refused</span> <span class="muted small">${problem}</span>` : html`<span class="tag tag-ok">may be called</span>`;
173
+ return problem ? html`<span class="tag tag-error">refused</span> <span class="muted small">${problem}</span>` : html`<span class="tag tag-ok">${tx('wres.may_be_called')}</span>`;
177
174
  };
178
175
  const form = editing || creating
179
- ? region(editing ? `Remote server ${editing.static_id}` : 'New remote server', html`<form method="post" action="${SERVERS}">${csrf(s)}
176
+ ? region(editing ? tx('wres.server_title', { id: editing.static_id }) : tx('wres.new_remote_server'), html`<form method="post" action="${SERVERS}">${csrf(s)}
180
177
  ${editing ? html`<input type="hidden" name="id" value="${editing.id}">` : ''}
181
178
  <div class="form-grid">
182
- ${input('static_id', 'Static id', v.static_id ?? '', { required: true, placeholder: 'CRM', help: 'Upper case letters, digits and _: REST data sources, catalogs and AI services name the server by it. Renaming it moves this workspace\'s sources and catalogs along.' })}
183
- ${input('name', 'Name', v.name ?? '', { required: true, placeholder: 'CRM system' })}
184
- ${input('base_url', 'Base URL (this installation)', v.base_url ?? '', { placeholder: 'https://crm.example.com/api', help: 'No trailing slash, query or fragment. Sources add their path after it.' })}
185
- ${input('env_var', 'Or from the environment variable', v.env_var ?? '', { placeholder: `${ENV_PREFIX}CRM`, help: `When the server's environment sets it, it wins over the base URL (one value per environment: development, test, production). Only ${ENV_PREFIX}… names.` })}
186
- ${input('credential', 'Default web credential', v.credential ?? '', { placeholder: 'e.g. CRM_API', help: 'Sources on this server that name no credential of their own use this one (an application\'s credential of that name, else the workspace\'s).' })}
187
- <div class="field" data-wide><label class="label" for="f_description">Description</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
179
+ ${input('static_id', tx('wres.static_id'), v.static_id ?? '', { required: true, placeholder: 'CRM', help: tx('wres.upper_case_letters_digits_and') })}
180
+ ${input('name', tx('wres.name'), v.name ?? '', { required: true, placeholder: tx('wres.crm_system') })}
181
+ ${input('base_url', tx('wres.base_url_this_installation'), v.base_url ?? '', { placeholder: tx('wres.https_crm_example_com_api'), help: tx('wres.no_trailing_slash_query_or') })}
182
+ ${input('env_var', tx('wres.or_from_the_environment_variable'), v.env_var ?? '', { placeholder: `${ENV_PREFIX}CRM`, help: tx('wres.env_help', { prefix: ENV_PREFIX }) })}
183
+ ${input('credential', tx('wres.default_web_credential'), v.credential ?? '', { placeholder: tx('wres.e_g_crm_api'), help: tx('wres.sources_on_this_server_that') })}
184
+ <div class="field" data-wide><label class="label" for="f_description">${tx('wres.description')}</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
188
185
  </div>
189
- <div class="buttons"><a class="btn" href="${SERVERS}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div></form>
186
+ <div class="buttons"><a class="btn" href="${SERVERS}">${tx('wres.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('wres.save')}</button></div></form>
190
187
  ${editing ? html`<form method="post" action="${SERVERS}/${editing.id}/delete" class="danger-zone">${csrf(s)}
191
- <button class="btn btn-danger" data-confirm="Delete remote server ${editing.static_id}? Sources on it fail until a server gets its static id again.">Delete server</button></form>` : ''}`, '', 'server-form')
188
+ <button class="btn btn-danger" data-confirm="${tx('wres.delete_server_confirm', { id: editing.static_id })}">${tx('wres.delete_server')}</button></form>` : ''}`, '', 'server-form')
192
189
  : '';
193
- return html`<div class="title-row"><h1>Remote servers</h1>${c.admin ? html`<a class="btn" href="${SERVERS}?new=1#server-form">${icon('plus')} New server</a>` : ''}</div>
194
- <p class="muted u-mt0">Named base URLs of ${ws.name}: a REST data source, a REST source catalog or an AI service names a server and a path
195
- instead of a full URL, so moving from development to production changes one value here (or one environment variable), and every source on
196
- the server follows at once. The URL still passes every check a full URL does: the installation's allow-list, this workspace's
197
- <a href="${BASE}/network">network access</a> list and the address checks when the call is made.</p>
190
+ return html`<div class="title-row"><h1>${tx('wres.remote_servers')}</h1>${c.admin ? html`<a class="btn" href="${SERVERS}?new=1#server-form">${icon('plus')} ${tx('wres.new_server')}</a>` : ''}</div>
191
+ <p class="muted u-mt0">${tx.rich('wres.servers_intro', { workspace: ws.name, network: html`<a href="${BASE}/network">${tx('wres.network_access')}</a>` })}</p>
198
192
  ${readOnlyNote(c)}
199
193
  ${form}
200
- ${region('Servers', html`<div class="table-wrap"><table class="report report-reflow">
201
- <thead><tr><th>Server</th><th>URL now</th><th>Network access</th><th>Default credential</th><th>Used by</th></tr></thead>
194
+ ${region(tx('wres.servers'), html`<div class="table-wrap"><table class="report report-reflow">
195
+ <thead><tr><th>${tx('wres.server')}</th><th>${tx('wres.url_now')}</th><th>${tx('wres.network_access_2')}</th><th>${tx('wres.default_credential')}</th><th>${tx('wres.used_by')}</th></tr></thead>
202
196
  <tbody>${servers.length ? servers.map((x) => {
203
197
  const u = serverUrl(x);
204
198
  return html`<tr>
205
- <td data-label="Server">${c.admin ? html`<a href="${SERVERS}?edit=${x.id}#server-form">${x.static_id}</a>` : x.static_id}<br><span class="muted small">${x.name}</span></td>
206
- <td data-label="URL now" class="u-break">${u.url ? html`<code>${u.url}</code>` : ''}<br><span class="muted small">${u.from === 'env' ? `from ${x.env_var}` : x.env_var ? `stored (${x.env_var} is not set)` : 'stored'}</span></td>
207
- <td data-label="Network access">${verdict(x)}</td>
208
- <td data-label="Default credential">${x.credential ?? html`<span class="muted">none</span>`}</td>
209
- <td data-label="Used by" class="u-break">${users.get(x.static_id)?.join(', ') || html`<span class="muted">nothing yet</span>`}</td></tr>`;
210
- }) : html`<tr><td colspan="5" class="empty">No remote servers yet.</td></tr>`}</tbody></table></div>`)}`;
199
+ <td data-label="${tx('wres.server')}">${c.admin ? html`<a href="${SERVERS}?edit=${x.id}#server-form">${x.static_id}</a>` : x.static_id}<br><span class="muted small">${x.name}</span></td>
200
+ <td data-label="${tx('wres.url_now')}" class="u-break">${u.url ? html`<code>${u.url}</code>` : ''}<br><span class="muted small">${u.from === 'env' ? `from ${x.env_var}` : x.env_var ? `stored (${x.env_var} is not set)` : 'stored'}</span></td>
201
+ <td data-label="${tx('wres.network_access_2')}">${verdict(x)}</td>
202
+ <td data-label="${tx('wres.default_credential')}">${x.credential ?? html`<span class="muted">none</span>`}</td>
203
+ <td data-label="${tx('wres.used_by')}" class="u-break">${users.get(x.static_id)?.join(', ') || html`<span class="muted">${tx('wres.nothing_yet')}</span>`}</td></tr>`;
204
+ }) : html`<tr><td colspan="5" class="empty">${tx('wres.no_remote_servers_yet')}</td></tr>`}</tbody></table></div>`)}`;
211
205
  }
212
206
 
213
207
  // ====================================================================== workspace web credentials
@@ -246,47 +240,45 @@ async function credentialsPage(c: Ctx, query: Record<string, string | undefined>
246
240
  const creating = c.admin && query.new === '1';
247
241
  const v: Partial<CredentialRow> = editing ?? { type: 'basic', grant_type: 'client_credentials' };
248
242
  const secret = (name: string, label: string, stored: boolean | undefined, help: string) =>
249
- html`${input(name, label, '', { type: 'password', auto: 'new-password', help: `${help} ${stored ? 'One is stored (encrypted): it is never shown; type a new one to replace it.' : 'Write-only: never shown again or exported.'}` })}
250
- ${stored ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_${name}" value="true"> Remove the stored ${label.toLowerCase()}</label></div>` : ''}`;
243
+ html`${input(name, label, '', { type: 'password', auto: 'new-password', help: `${help} ${stored ? tx('wres.one_is_stored_encrypted_it') : tx('wres.write_only_never_shown_again')}` })}
244
+ ${stored ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_${name}" value="true"> ${tx('wres.remove_stored', { what: label.toLowerCase() })}</label></div>` : ''}`;
251
245
  const form = editing || creating
252
- ? region(editing ? `Web credential ${editing.name}` : 'New web credential', html`<form method="post" action="${CREDS}">${csrf(s)}
246
+ ? region(editing ? tx('wres.credential_title', { name: editing.name }) : tx('wres.new_web_credential'), html`<form method="post" action="${CREDS}">${csrf(s)}
253
247
  ${editing ? html`<input type="hidden" name="id" value="${editing.id}">` : ''}
254
- ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">The server has no <code>PGKILN_SECRET_KEY</code>: secrets can't be stored until it is set.</div>`}
248
+ ${secretKeyConfigured() ? '' : html`<div class="alert alert-error" role="alert">${tx.code('wres.no_secret_key')}</div>`}
255
249
  <div class="form-grid">
256
- ${input('name', 'Name', v.name ?? '', { required: true, placeholder: 'CRM_API', help: 'The applications of the workspace use it by this name (an application\'s own credential of the same name wins).' })}
257
- ${select('type', 'Authentication', v.type ?? 'basic', ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'], 'basic: user name + password · header: an HTTP header with the secret (API key) · bearer: Authorization: Bearer <secret> · oauth2: client credentials, password or refresh token grant · aws_sigv4: an S3-compatible object store.')}
258
- ${input('username', 'User name / client id / access key id', v.username ?? '')}
259
- ${select('grant_type', 'Grant type (oauth2)', v.grant_type ?? 'client_credentials', GRANT_TYPES)}
260
- ${input('oauth_username', 'OAuth2 user name (password grant)', v.oauth_username ?? '')}
261
- ${input('header_name', 'Header name (header)', v.header_name ?? '', { placeholder: 'X-API-Key' })}
262
- ${input('token_url', 'Token URL (oauth2)', v.token_url ?? '', { placeholder: 'https://login.example.com/oauth/token' })}
263
- ${input('scope', 'Scope (oauth2) / region (aws_sigv4)', v.scope ?? '')}
264
- ${input('valid_for', 'Valid for URLs', (v.valid_for ?? []).join(', '), { placeholder: 'https://crm.example.com/api/', help: 'Comma separated URL prefixes: the credential is only ever sent to these. Recommended.' })}
265
- ${secret('secret', 'Secret', v.has_secret, 'The password, header value, token, client secret or secret access key.')}
266
- ${secret('password', 'OAuth2 password', v.has_password, 'The password grant: the OAuth2 user\'s password.')}
267
- ${secret('refresh_token', 'Refresh token', v.has_refresh, 'The refresh token grant: a refresh token to start with.')}
268
- <div class="field" data-wide><label class="label" for="f_description">Description</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
250
+ ${input('name', tx('wres.name'), v.name ?? '', { required: true, placeholder: 'CRM_API', help: tx('wres.the_applications_of_the_workspace') })}
251
+ ${select('type', tx('wres.authentication'), v.type ?? 'basic', ['basic', 'header', 'bearer', 'oauth2', 'aws_sigv4'], tx('wres.type_help'))}
252
+ ${input('username', tx('wres.user_name_client_id_access'), v.username ?? '')}
253
+ ${select('grant_type', tx('wres.grant_type_oauth2'), v.grant_type ?? 'client_credentials', GRANT_TYPES)}
254
+ ${input('oauth_username', tx('wres.oauth2_user_name_password_grant'), v.oauth_username ?? '')}
255
+ ${input('header_name', tx('wres.header_name_header'), v.header_name ?? '', { placeholder: tx('wres.x_api_key') })}
256
+ ${input('token_url', tx('wres.token_url_oauth2'), v.token_url ?? '', { placeholder: tx('wres.https_login_example_com_oauth') })}
257
+ ${input('scope', tx('wres.scope_oauth2_region_aws_sigv4'), v.scope ?? '')}
258
+ ${input('valid_for', tx('wres.valid_for_urls'), (v.valid_for ?? []).join(', '), { placeholder: tx('wres.https_crm_example_com_api_2'), help: tx('wres.comma_separated_url_prefixes_the') })}
259
+ ${secret('secret', tx('wres.secret'), v.has_secret, tx('wres.the_password_header_value_token'))}
260
+ ${secret('password', 'OAuth2 password', v.has_password, tx('wres.the_password_grant_the_oauth2'))}
261
+ ${secret('refresh_token', tx('wres.refresh_token'), v.has_refresh, tx('wres.the_refresh_token_grant_a'))}
262
+ <div class="field" data-wide><label class="label" for="f_description">${tx('wres.description')}</label><textarea id="f_description" name="description" rows="2">${v.description ?? ''}</textarea></div>
269
263
  </div>
270
- <div class="buttons"><a class="btn" href="${CREDS}">Cancel</a><button class="btn btn-hot">${icon('check')} Save</button></div></form>
264
+ <div class="buttons"><a class="btn" href="${CREDS}">${tx('wres.cancel')}</a><button class="btn btn-hot">${icon('check')} ${tx('wres.save')}</button></div></form>
271
265
  ${editing ? html`<form method="post" action="${CREDS}/${editing.id}/delete" class="danger-zone">${csrf(s)}
272
- <button class="btn btn-danger" data-confirm="Delete web credential ${editing.name}? Applications that use it fail until one of that name exists.">Delete credential</button></form>` : ''}`, '', 'credential-form')
266
+ <button class="btn btn-danger" data-confirm="${tx('wres.delete_credential_confirm', { name: editing.name })}">${tx('wres.delete_credential')}</button></form>` : ''}`, '', 'credential-form')
273
267
  : '';
274
- return html`<div class="title-row"><h1>Workspace web credentials</h1>${c.admin ? html`<a class="btn" href="${CREDS}?new=1#credential-form">${icon('plus')} New credential</a>` : ''}</div>
275
- <p class="muted u-mt0">Web credentials of ${ws.name} that its applications use by name in REST data sources, the Invoke API process,
276
- remote servers and object storage, so a key is entered once for the workspace. An application's own credential (Shared Components →
277
- Web credentials) with the same name wins. Secrets are encrypted with the server's key, never shown again and never exported.</p>
268
+ return html`<div class="title-row"><h1>${tx('wres.workspace_web_credentials')}</h1>${c.admin ? html`<a class="btn" href="${CREDS}?new=1#credential-form">${icon('plus')} ${tx('wres.new_credential')}</a>` : ''}</div>
269
+ <p class="muted u-mt0">${tx('wres.credentials_intro', { workspace: ws.name })}</p>
278
270
  ${readOnlyNote(c)}
279
271
  ${form}
280
- ${region('Credentials', html`<div class="table-wrap"><table class="report report-reflow">
281
- <thead><tr><th>Name</th><th>Authentication</th><th>Secret</th><th>Valid for</th><th>Overridden by</th><th>Changed (UTC)</th></tr></thead>
272
+ ${region(tx('wres.credentials'), html`<div class="table-wrap"><table class="report report-reflow">
273
+ <thead><tr><th>${tx('wres.name')}</th><th>${tx('wres.authentication')}</th><th>${tx('wres.secret')}</th><th>${tx('wres.valid_for')}</th><th>${tx('wres.overridden_by')}</th><th>${tx('wres.changed_utc')}</th></tr></thead>
282
274
  <tbody>${creds.length ? creds.map((x) => html`<tr>
283
- <td data-label="Name">${c.admin ? html`<a href="${CREDS}?edit=${x.id}#credential-form">${x.name}</a>` : x.name}${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
284
- <td data-label="Authentication">${x.type}${x.type === 'oauth2' ? html` <span class="muted">(${x.grant_type})</span>` : ''}</td>
285
- <td data-label="Secret">${x.has_secret || x.has_password || x.has_refresh ? 'stored (encrypted)' : html`<span class="muted">none yet</span>`}</td>
286
- <td data-label="Valid for" class="u-break">${x.valid_for.length ? x.valid_for.join(', ') : html`<span class="muted">any URL</span>`}</td>
287
- <td data-label="Overridden by" class="u-break">${shadowed.find((r) => r.name === x.name)?.apps ?? html`<span class="muted">-</span>`}</td>
288
- <td data-label="Changed">${when(x.updated_at)}${x.updated_by ? html` <span class="muted">${x.updated_by}</span>` : ''}</td></tr>`)
289
- : html`<tr><td colspan="6" class="empty">No workspace web credentials yet.</td></tr>`}</tbody></table></div>`)}`;
275
+ <td data-label="${tx('wres.name')}">${c.admin ? html`<a href="${CREDS}?edit=${x.id}#credential-form">${x.name}</a>` : x.name}${x.description ? html`<br><span class="muted small">${x.description}</span>` : ''}</td>
276
+ <td data-label="${tx('wres.authentication')}">${x.type}${x.type === 'oauth2' ? html` <span class="muted">(${x.grant_type})</span>` : ''}</td>
277
+ <td data-label="${tx('wres.secret')}">${x.has_secret || x.has_password || x.has_refresh ? 'stored (encrypted)' : html`<span class="muted">${tx('wres.none_yet')}</span>`}</td>
278
+ <td data-label="${tx('wres.valid_for')}" class="u-break">${x.valid_for.length ? x.valid_for.join(', ') : html`<span class="muted">${tx('wres.any_url')}</span>`}</td>
279
+ <td data-label="${tx('wres.overridden_by')}" class="u-break">${shadowed.find((r) => r.name === x.name)?.apps ?? html`<span class="muted">-</span>`}</td>
280
+ <td data-label="${tx('wres.changed')}">${when(x.updated_at)}${x.updated_by ? html` <span class="muted">${x.updated_by}</span>` : ''}</td></tr>`)
281
+ : html`<tr><td colspan="6" class="empty">${tx('wres.no_workspace_web_credentials_yet')}</td></tr>`}</tbody></table></div>`)}`;
290
282
  }
291
283
 
292
284
  // ====================================================================== routes
@@ -294,14 +286,14 @@ async function credentialsPage(c: Ctx, query: Record<string, string | undefined>
294
286
  export async function workspaceResourceRoutes(app: FastifyInstance) {
295
287
  // ---------------------------------------------------------------- files
296
288
  app.get(FILES, async (req: Req, reply) => {
297
- const c = await context(req, reply, 'Static workspace files');
289
+ const c = await context(req, reply, tx('wres.files_title'));
298
290
  if (!c) return;
299
- return send(reply, c.s, shell(c.s, 'Static workspace files', crumbs('Static workspace files'), await filesPage(c, req.query ?? {})));
291
+ return send(reply, c.s, shell(c.s, tx('wres.files_title'), crumbs(tx('wres.files_title')), await filesPage(c, req.query ?? {})));
300
292
  });
301
293
 
302
294
  // a file for the workspace's developers, with the headers the applications serve it with
303
295
  app.get(`${FILES}/file/:name`, async (req: Req, reply) => {
304
- const c = await context(req, reply, 'Static workspace files');
296
+ const c = await context(req, reply, tx('wres.files_title'));
305
297
  if (!c) return;
306
298
  const type = staticType(String(req.params.name));
307
299
  const f = type ? await owner.one<{ content: Buffer }>('select content from meta.workspace_file where workspace_id = $1 and name = $2', [c.ws.id, req.params.name]) : null;
@@ -320,11 +312,11 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
320
312
  req.body = parsed.body as Body;
321
313
  uploads = parsed.lists.get('file') ?? [];
322
314
  }
323
- const c = await manager(req, reply, 'Static workspace files');
315
+ const c = await manager(req, reply, tx('wres.files_title'));
324
316
  if (!c) return;
325
317
  const { s } = c;
326
318
  if (!uploads.length) {
327
- flash(s, 'Choose one or more files.', 'error');
319
+ flash(s, tx('wres.choose_one_or_more_files'), 'error');
328
320
  return back(reply, s, FILES);
329
321
  }
330
322
  const saved: string[] = [];
@@ -346,7 +338,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
346
338
  });
347
339
 
348
340
  app.post(`${FILES}/save`, async (req: Req, reply) => {
349
- const c = await manager(req, reply, 'Static workspace files');
341
+ const c = await manager(req, reply, tx('wres.files_title'));
350
342
  if (!c) return;
351
343
  const { s } = c;
352
344
  const b = req.body ?? {};
@@ -355,47 +347,47 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
355
347
  const type = staticType(name);
356
348
  const content = Buffer.from(String(b.content ?? '').replace(/\r\n/g, '\n'), 'utf8');
357
349
  if (!type || !editable(name)) {
358
- flash(s, 'The name must end in a text type (.js, .css, .json, .txt, .csv, .md, .map) and use letters, digits, _, - and . only.', 'error');
350
+ flash(s, tx('wres.the_name_must_end_in'), 'error');
359
351
  return back(reply, s, original ? `${FILES}?edit=${encodeURIComponent(original)}` : `${FILES}?new=1`);
360
352
  }
361
353
  if (content.length > STATIC_MAX) {
362
- flash(s, `The file is larger than ${size(STATIC_MAX)}.`, 'error');
354
+ flash(s, tx('wres.too_large', { size: size(STATIC_MAX) }), 'error');
363
355
  return back(reply, s, FILES);
364
356
  }
365
357
  try {
366
358
  if (original && original !== name) {
367
359
  const r = await owner.query('update meta.workspace_file set name = $3, mime = $4, content = $5, updated_by = $6, updated_at = now() where workspace_id = $1 and name = $2',
368
360
  [c.ws.id, original, name, type, content, s.username]);
369
- if (!r.rowCount) throw new Error('No such file.');
361
+ if (!r.rowCount) throw new Error(tx('wres.no_such_file'));
370
362
  } else await storeFile(c.ws.id, name, type, content, s.username);
371
363
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} file saved: ${name}` });
372
364
  flash(s, `${name} saved.`);
373
365
  return back(reply, s, `${FILES}?edit=${encodeURIComponent(name)}`);
374
366
  } catch (e) {
375
- flash(s, (e as Error).message.includes('workspace_file_workspace_id_name_key') ? `There is a file ${name} already.` : (e as Error).message, 'error');
367
+ flash(s, (e as Error).message.includes('workspace_file_workspace_id_name_key') ? tx('wres.file_exists', { name }) : (e as Error).message, 'error');
376
368
  return back(reply, s, FILES);
377
369
  }
378
370
  });
379
371
 
380
372
  app.post(`${FILES}/delete`, async (req: Req, reply) => {
381
- const c = await manager(req, reply, 'Static workspace files');
373
+ const c = await manager(req, reply, tx('wres.files_title'));
382
374
  if (!c) return;
383
375
  const name = String(req.body?.name ?? '');
384
376
  const r = await owner.query('delete from meta.workspace_file where workspace_id = $1 and name = $2', [c.ws.id, name]);
385
377
  if (r.rowCount) await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} file deleted: ${name}` });
386
- flash(c.s, r.rowCount ? `${name} deleted.` : 'No such file.', r.rowCount ? 'ok' : 'error');
378
+ flash(c.s, r.rowCount ? `${name} deleted.` : tx('wres.no_such_file'), r.rowCount ? 'ok' : 'error');
387
379
  return back(reply, c.s, FILES);
388
380
  });
389
381
 
390
382
  // ---------------------------------------------------------------- remote servers
391
383
  app.get(SERVERS, async (req: Req, reply) => {
392
- const c = await context(req, reply, 'Remote servers');
384
+ const c = await context(req, reply, tx('wres.servers_title'));
393
385
  if (!c) return;
394
- return send(reply, c.s, shell(c.s, 'Remote servers', crumbs('Remote servers'), await serversPage(c, req.query ?? {})));
386
+ return send(reply, c.s, shell(c.s, tx('wres.servers_title'), crumbs(tx('wres.servers_title')), await serversPage(c, req.query ?? {})));
395
387
  });
396
388
 
397
389
  app.post(SERVERS, async (req: Req, reply) => {
398
- const c = await manager(req, reply, 'Remote servers');
390
+ const c = await manager(req, reply, tx('wres.servers_title'));
399
391
  if (!c) return;
400
392
  const { s } = c;
401
393
  const b = req.body ?? {};
@@ -415,7 +407,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
415
407
  await owner.tx(async (db) => {
416
408
  if (editId) {
417
409
  const old = (await db.query<{ static_id: string }>('select static_id from meta.remote_server where id = $1 and workspace_id = $2 for update', [editId, c.ws.id])).rows[0];
418
- if (!old) throw new Error('No such remote server.');
410
+ if (!old) throw new Error(tx('wres.no_such_remote_server'));
419
411
  await db.query(
420
412
  `update meta.remote_server set static_id = $3, name = $4, description = $5, base_url = $6, env_var = $7, credential = $8, updated_by = $9, updated_at = now()
421
413
  where id = $1 and workspace_id = $2`, [editId, c.ws.id, v.static_id, v.name, v.description, v.base_url, v.env_var, v.credential, s.username]);
@@ -433,35 +425,35 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
433
425
  clearResponseCache();
434
426
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req),
435
427
  detail: `workspace ${c.ws.id} remote server ${v.static_id} ${editId ? 'changed' : 'added'}: ${v.base_url ?? ''}${v.env_var ? ` (${v.env_var})` : ''}`.slice(0, 1000) });
436
- flash(s, `Remote server ${v.static_id} saved.`);
428
+ flash(s, tx('wres.server_saved', { id: v.static_id }));
437
429
  return back(reply, s, SERVERS);
438
430
  } catch (e) {
439
- flash(s, (e as Error).message.includes('remote_server_workspace_id_static_id_key') ? `This workspace has a remote server ${v.static_id} already.` : (e as Error).message, 'error');
431
+ flash(s, (e as Error).message.includes('remote_server_workspace_id_static_id_key') ? tx('wres.server_exists', { id: v.static_id }) : (e as Error).message, 'error');
440
432
  return back(reply, s, retry);
441
433
  }
442
434
  });
443
435
 
444
436
  app.post(`${SERVERS}/:sid/delete`, async (req: Req, reply) => {
445
- const c = await manager(req, reply, 'Remote servers');
437
+ const c = await manager(req, reply, tx('wres.servers_title'));
446
438
  if (!c) return;
447
439
  const r = await owner.one<{ static_id: string }>('delete from meta.remote_server where id = $1 and workspace_id = $2 returning static_id', [id(req.params.sid), c.ws.id]);
448
440
  if (r) {
449
441
  clearResponseCache();
450
442
  await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} remote server ${r.static_id} deleted` });
451
443
  }
452
- flash(c.s, r ? `Remote server ${r.static_id} deleted.` : 'No such remote server.', r ? 'ok' : 'error');
444
+ flash(c.s, r ? tx('wres.server_deleted', { id: r.static_id }) : tx('wres.no_such_remote_server'), r ? 'ok' : 'error');
453
445
  return back(reply, c.s, SERVERS);
454
446
  });
455
447
 
456
448
  // ---------------------------------------------------------------- web credentials
457
449
  app.get(CREDS, async (req: Req, reply) => {
458
- const c = await context(req, reply, 'Workspace web credentials');
450
+ const c = await context(req, reply, tx('wres.credentials_title'));
459
451
  if (!c) return;
460
- return send(reply, c.s, shell(c.s, 'Workspace web credentials', crumbs('Workspace web credentials'), await credentialsPage(c, req.query ?? {})));
452
+ return send(reply, c.s, shell(c.s, tx('wres.credentials_title'), crumbs(tx('wres.credentials_title')), await credentialsPage(c, req.query ?? {})));
461
453
  });
462
454
 
463
455
  app.post(CREDS, async (req: Req, reply) => {
464
- const c = await manager(req, reply, 'Workspace web credentials');
456
+ const c = await manager(req, reply, tx('wres.credentials_title'));
465
457
  if (!c) return;
466
458
  const { s } = c;
467
459
  const b = req.body ?? {};
@@ -475,7 +467,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
475
467
  valid_for: [...new Set(String(b.valid_for ?? '').split(',').map((x) => x.trim()).filter(Boolean))],
476
468
  grant_type: GRANT_TYPES.includes(String(b.grant_type)) ? String(b.grant_type) : 'client_credentials', oauth_username: text('oauth_username'),
477
469
  };
478
- const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : ['The name is upper case letters, digits and _, starting with a letter, e.g. CRM_API.']), ...credentialProblems(v)];
470
+ const problems = [...(/^[A-Z][A-Z0-9_]{0,59}$/.test(v.name) ? [] : [tx('wres.the_name_is_upper_case')]), ...credentialProblems(v)];
479
471
  if (problems.length) {
480
472
  flash(s, problems.join(' '), 'error');
481
473
  return back(reply, s, retry);
@@ -495,7 +487,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
495
487
  ${sets.join(', ')}, token_refreshed_at = case when $18::text is not null or $19 then null else token_refreshed_at end
496
488
  where id = $1 and workspace_id = $2`,
497
489
  [editId, c.ws.id, ...vals, s.username, secrets.secret_enc, remove.secret_enc, secrets.password_enc, remove.password_enc, secrets.refresh_token_enc, remove.refresh_token_enc]);
498
- if (!r.rowCount) throw new Error('No such web credential.');
490
+ if (!r.rowCount) throw new Error(tx('wres.no_such_web_credential'));
499
491
  clearTokens(`w${editId}`);
500
492
  } else
501
493
  await owner.query(
@@ -504,16 +496,16 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
504
496
  [c.ws.id, ...vals, s.username, secrets.secret_enc, secrets.password_enc, secrets.refresh_token_enc]);
505
497
  clearResponseCache();
506
498
  await logActivity({ appId: null, username: s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} web credential ${v.name} ${editId ? 'changed' : 'added'}` });
507
- flash(s, `Web credential ${v.name} saved.`);
499
+ flash(s, tx('wres.credential_saved', { name: v.name }));
508
500
  return back(reply, s, CREDS);
509
501
  } catch (e) {
510
- flash(s, (e as Error).message.includes('workspace_credential_workspace_id_name_key') ? `This workspace has a web credential ${v.name} already.` : (e as Error).message, 'error');
502
+ flash(s, (e as Error).message.includes('workspace_credential_workspace_id_name_key') ? tx('wres.credential_exists', { name: v.name }) : (e as Error).message, 'error');
511
503
  return back(reply, s, retry);
512
504
  }
513
505
  });
514
506
 
515
507
  app.post(`${CREDS}/:cid/delete`, async (req: Req, reply) => {
516
- const c = await manager(req, reply, 'Workspace web credentials');
508
+ const c = await manager(req, reply, tx('wres.credentials_title'));
517
509
  if (!c) return;
518
510
  const r = await owner.one<{ id: number; name: string }>('delete from meta.workspace_credential where id = $1 and workspace_id = $2 returning id, name', [id(req.params.cid), c.ws.id]);
519
511
  if (r) {
@@ -521,7 +513,7 @@ export async function workspaceResourceRoutes(app: FastifyInstance) {
521
513
  clearResponseCache();
522
514
  await logActivity({ appId: null, username: c.s.username, event: 'workspace', ip: clientIp(req), detail: `workspace ${c.ws.id} web credential ${r.name} deleted` });
523
515
  }
524
- flash(c.s, r ? `Web credential ${r.name} deleted.` : 'No such web credential.', r ? 'ok' : 'error');
516
+ flash(c.s, r ? tx('wres.credential_deleted', { name: r.name }) : tx('wres.no_such_web_credential'), r ? 'ok' : 'error');
525
517
  return back(reply, c.s, CREDS);
526
518
  });
527
519
  }