pgkiln 0.57.0 → 0.58.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.
@@ -16,6 +16,7 @@ import { dataLoadDefExtras } from './dataload.ts';
16
16
  import { appHeader, back, BASE, designerUrl, csrf, developer, flash, input, region, select, send, shell, type Req } from './ui.ts';
17
17
  import { endSessions, grantAccess, roleHints, roleHintsHtml, splitRoles } from './users.ts';
18
18
  import { appOr404, componentForm, lookups, saveComponent } from './forms.ts';
19
+ import { tr, type BuilderTranslate } from './i18n.ts';
19
20
  import { catalogPicker } from './rest-catalogs.ts';
20
21
  import { COMPONENT_SETTINGS, componentSetting, componentSettingsFrom, type ComponentSettings } from '../runtime/component-settings.ts';
21
22
 
@@ -23,14 +24,14 @@ import { COMPONENT_SETTINGS, componentSetting, componentSettingsFrom, type Compo
23
24
  // items and processes, report layouts, and access control.
24
25
 
25
26
  /** A list's entries, with links to edit them and to add one. */
26
- function listExtras(appId: number, list: any, entries: any[]): Raw {
27
+ function listExtras(appId: number, list: any, entries: any[], tx: BuilderTranslate): Raw {
27
28
  const own = entries.filter((e) => e.list_name === list.name);
28
- return html`<h3>Entries</h3>
29
- ${list.type === 'sql' ? html`<p class="muted">This list shows the rows of its query; entries below are not used.</p>` : ''}
29
+ return html`<h3>${tx('sc.entries')}</h3>
30
+ ${list.type === 'sql' ? html`<p class="muted">${tx('sc.list_sql')}</p>` : ''}
30
31
  ${own.length
31
32
  ? html`<ul class="tree">${own.map((e) => html`<li><a href="?c=list_entry-${e.id}">${icon(e.icon ?? 'chevron')}<span>${e.parent_id ? '↳ ' : ''}${e.label}</span>${e.target_page ? html`<span class="kind">p${e.target_page}</span>` : e.target_url ? html`<span class="kind">url</span>` : ''}</a></li>`)}</ul>`
32
- : html`<p class="muted">No entries yet.</p>`}
33
- <p><a class="btn" href="?new=list_entry&amp;list=${encodeURIComponent(list.name)}">+ Add entry</a></p>`;
33
+ : html`<p class="muted">${tx('sc.no_entries')}</p>`}
34
+ <p><a class="btn" href="?new=list_entry&amp;list=${encodeURIComponent(list.name)}">${tx('sc.add_entry')}</a></p>`;
34
35
  }
35
36
 
36
37
  /** (sprint 74) A row's second column in a component list. */
@@ -53,6 +54,10 @@ function detailOf(kind: string, r: any): string {
53
54
  }
54
55
 
55
56
  interface ScItem { label: string; href: string; kind?: string }
57
+ /** (sprint 124) A Shared Components link's text in the builder language: key sc.item.<its English label as a slug>. */
58
+ const itemLabel = (tx: BuilderTranslate, label: string) => tx.dyn(`sc.item.${label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_|_$/g, '')}`, label);
59
+ /** (sprint 124) A component type's name, singular or plural, in the builder language (comp.<kind>[.plural]). */
60
+ const kindName = (tx: BuilderTranslate, kind: string, plural = false) => tx.dyn(`comp.${kind}${plural ? '.plural' : ''}`, plural ? COMPONENTS[kind].plural : COMPONENTS[kind].label);
56
61
  /**
57
62
  * (sprint 74) The groups of the Shared Components page, like APEX's. Component types pgkiln keeps in Settings
58
63
  * open the right Settings tab; a few APEX types have no counterpart (docs/apex-feature-parity.md says why).
@@ -80,6 +85,7 @@ export async function sharedRoutes(app: FastifyInstance) {
80
85
  app.get(`${BASE}/apps/:id/breadcrumbs`, async (req: Req, reply) => {
81
86
  const s = await developer(req, reply);
82
87
  if (!s) return;
88
+ const tx = tr(s);
83
89
  const a = await appOr404(req.params.id);
84
90
  if (!a) return reply.code(404).send('Not found');
85
91
  const pages = (await owner.query<{ id: number; page_no: number; name: string; parent_page: number | null }>('select id, page_no, name, parent_page from meta.page where app_id = $1 order by page_no', [a.id])).rows;
@@ -93,26 +99,27 @@ export async function sharedRoutes(app: FastifyInstance) {
93
99
  })}</ul>`;
94
100
  };
95
101
  const main = html`${appHeader(a, 'shared', s)}
96
- ${region('Breadcrumbs', html`<p class="muted u-mt0">A page's breadcrumb lists the pages above it: each page names its breadcrumb parent (Page Designer → page → Breadcrumb parent). Pages without one show Home.</p>${tree(null, new Set())}`)}`;
97
- return send(reply, s, shell(s, 'Breadcrumbs', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Breadcrumbs']], main));
102
+ ${region(tx('sc.item.breadcrumbs'), html`<p class="muted u-mt0">${tx('sc.breadcrumbs_help')}</p>${tree(null, new Set())}`)}`;
103
+ return send(reply, s, shell(s, tx('sc.item.breadcrumbs'), [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], [tx('sc.item.breadcrumbs')]], main));
98
104
  });
99
105
 
100
106
  // (105) Component Settings (APEX): the application's defaults of its built-in component types
101
107
  app.get(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
102
108
  const s = await developer(req, reply);
103
109
  if (!s) return;
110
+ const tx = tr(s);
104
111
  const a = await appOr404(req.params.id);
105
112
  if (!a) return reply.code(404).send('Not found');
106
113
  const cur = (a.component_settings ?? {}) as ComponentSettings;
107
114
  const groups = [...new Set(COMPONENT_SETTINGS.map((x) => x.group))];
108
115
  const main = html`${appHeader(a, 'shared', s)}
109
- ${region('Component Settings', html`<p class="muted u-mt0">Defaults for the application's built-in component types, used where a component sets nothing itself. Empty: pgkiln's default.</p>
116
+ ${region(tx('sc.item.component_settings'), html`<p class="muted u-mt0">${tx('sc.cs_help')}</p>
110
117
  <form method="post" action="${BASE}/apps/${a.id}/component-settings">${csrf(s)}
111
118
  ${groups.map((g) => html`<fieldset class="u-mb1"><legend>${g}</legend>${COMPONENT_SETTINGS.filter((x) => x.group === g).map((x) =>
112
- input(x.key, x.label, cur[x.key] ?? '', { type: 'number', placeholder: String(x.def), help: `${x.help} ${x.min}–${x.max.toLocaleString('en')}; now ${componentSetting(cur, x.key).toLocaleString('en')}.` }))}</fieldset>`)}
113
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
119
+ input(x.key, x.label, cur[x.key] ?? '', { type: 'number', placeholder: String(x.def), help: tx('sc.cs_now', { help: x.help, min: x.min, max: x.max.toLocaleString(tx.lang), now: componentSetting(cur, x.key).toLocaleString(tx.lang) }) }))}</fieldset>`)}
120
+ <div class="buttons"><button class="btn btn-hot">${tx('sc.save')}</button></div>
114
121
  </form>`)}`;
115
- return send(reply, s, shell(s, 'Component Settings', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Component Settings']], main));
122
+ return send(reply, s, shell(s, tx('sc.item.component_settings'), [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], [tx('sc.item.component_settings')]], main));
116
123
  });
117
124
 
118
125
  app.post(`${BASE}/apps/:id/component-settings`, async (req: Req, reply) => {
@@ -124,7 +131,7 @@ export async function sharedRoutes(app: FastifyInstance) {
124
131
  if (problems.length) flash(s, problems.join(' '), 'error');
125
132
  else {
126
133
  await owner.query('update meta.app set component_settings = $2 where id = $1', [a.id, JSON.stringify(settings)]);
127
- flash(s, 'Component settings saved.');
134
+ flash(s, tr(s)('sc.cs_saved'));
128
135
  }
129
136
  return back(reply, s, `${BASE}/apps/${a.id}/component-settings`);
130
137
  });
@@ -140,6 +147,7 @@ export async function sharedRoutes(app: FastifyInstance) {
140
147
  app.get(`${BASE}/apps/:id/shared`, async (req: Req, reply) => {
141
148
  const s = await developer(req, reply);
142
149
  if (!s) return;
150
+ const tx = tr(s);
143
151
  const a = await appOr404(req.params.id);
144
152
  if (!a) return reply.code(404).send('Not found');
145
153
  const lk = await lookups(a.id);
@@ -164,10 +172,10 @@ export async function sharedRoutes(app: FastifyInstance) {
164
172
  const counts: Record<string, number> = Object.fromEntries(SHARED.map((k) => [k, rows[k].length]));
165
173
  counts.access = users.length;
166
174
  const main = html`${appHeader(a, 'shared', s)}
167
- <div class="sc-head"><p class="muted u-m0">The components the pages of ${a.name} share.</p><a class="btn" href="?view=tree">${icon('list')} Tree view</a></div>
175
+ <div class="sc-head"><p class="muted u-m0">${tx('sc.intro', { app: a.name })}</p><a class="btn" href="?view=tree">${icon('list')} ${tx('sc.tree_view')}</a></div>
168
176
  <div class="sc-grid">${sharedGroups(a.id).map((g) => html`<section class="region region-standard sc-group" aria-labelledby="sc-${g.key}">
169
- <header class="region-header"><h2 id="sc-${g.key}">${icon(g.icon)} ${g.title}</h2></header>
170
- <ul class="sc-links">${g.items.map((it) => html`<li><a href="${it.href}">${it.label}</a>${it.kind && counts[it.kind] !== undefined ? html`<span class="sc-count" aria-label="${counts[it.kind]} ${it.label}">${counts[it.kind]}</span>` : ''}</li>`)}</ul>
177
+ <header class="region-header"><h2 id="sc-${g.key}">${icon(g.icon)} ${tx.dyn(`sc.group.${g.key}`, g.title)}</h2></header>
178
+ <ul class="sc-links">${g.items.map((it) => html`<li><a href="${it.href}">${itemLabel(tx, it.label)}</a>${it.kind && counts[it.kind] !== undefined ? html`<span class="sc-count" aria-label="${counts[it.kind]} ${itemLabel(tx, it.label)}">${counts[it.kind]}</span>` : ''}</li>`)}</ul>
171
179
  </section>`)}</div>`;
172
180
  return send(reply, s, shell(s, 'Shared Components', [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components']], main));
173
181
  }
@@ -181,26 +189,27 @@ export async function sharedRoutes(app: FastifyInstance) {
181
189
  .filter((x) => !q || x.name.toLowerCase().includes(q.toLowerCase()))
182
190
  .sort((x, y) => (desc ? -1 : 1) * x.name.localeCompare(y.name, 'en', { numeric: true }));
183
191
  const main = html`${appHeader(a, 'shared', s)}
184
- <div class="sc-list-head"><h1>${spec.plural}</h1><a class="btn btn-hot" href="?new=${type}">${icon('plus')} Create</a></div>
192
+ <div class="sc-list-head"><h1>${kindName(tx, type, true)}</h1><a class="btn btn-hot" href="?new=${type}">${icon('plus')} ${tx('sc.create')}</a></div>
185
193
  <form method="get" class="search u-mwnone u-mb1" role="search"><input type="hidden" name="type" value="${type}">
186
- <div class="field"><label class="label" for="f_q">Search</label><input id="f_q" name="q" type="search" value="${q}" placeholder="Name" data-filter-rows="sc-rows"></div>
187
- <button class="btn u-selfend">${icon('search')} Go</button></form>
194
+ <div class="field"><label class="label" for="f_q">${tx('sc.search')}</label><input id="f_q" name="q" type="search" value="${q}" placeholder="${tx('sc.name')}" data-filter-rows="sc-rows"></div>
195
+ <button class="btn u-selfend">${icon('search')} ${tx('common.go')}</button></form>
188
196
  <div class="table-wrap"><table class="report"><thead><tr>
189
- <th scope="col" aria-sort="${desc ? 'descending' : 'ascending'}"><a href="?type=${type}${q ? `&q=${encodeURIComponent(q)}` : ''}${desc ? '' : '&sort=desc'}">Name ${desc ? '▼' : '▲'}</a></th>
190
- <th scope="col">Details</th></tr></thead>
197
+ <th scope="col" aria-sort="${desc ? 'descending' : 'ascending'}"><a href="?type=${type}${q ? `&q=${encodeURIComponent(q)}` : ''}${desc ? '' : '&sort=desc'}">${tx('sc.name')} ${desc ? '▼' : '▲'}</a></th>
198
+ <th scope="col">${tx('sc.details')}</th></tr></thead>
191
199
  <tbody id="sc-rows">${shown.length
192
- ? shown.map(({ r, name }) => html`<tr><td data-label="Name"><a href="?c=${type}-${r.id}">${icon(type === 'nav_entry' ? (r.icon ?? 'chevron') : spec.icon)} ${r.parent_id ? '↳ ' : ''}${name}</a></td>
193
- <td data-label="Details">${detailOf(type, r)}</td></tr>`)
194
- : html`<tr><td colspan="2" class="empty">${q ? 'Nothing matches.' : `No ${spec.plural.toLowerCase()} yet.`}</td></tr>`}</tbody></table></div>
195
- <p class="muted">${shown.length} of ${rows[type].length}</p>`;
196
- return send(reply, s, shell(s, spec.plural, [...crumbsBase, [spec.plural]], main));
200
+ ? shown.map(({ r, name }) => html`<tr><td data-label="${tx('sc.name')}"><a href="?c=${type}-${r.id}">${icon(type === 'nav_entry' ? (r.icon ?? 'chevron') : spec.icon)} ${r.parent_id ? '↳ ' : ''}${name}</a></td>
201
+ <td data-label="${tx('sc.details')}">${detailOf(type, r)}</td></tr>`)
202
+ // English lower-cases the type in the sentence; other languages keep the name as it is (German nouns)
203
+ : html`<tr><td colspan="2" class="empty">${q ? tx('sc.nothing_matches') : tx('sc.none_yet', { what: tx.lang === 'en' ? spec.plural.toLowerCase() : kindName(tx, type, true) })}</td></tr>`}</tbody></table></div>
204
+ <p class="muted">${tx('sc.count', { n: shown.length, total: rows[type].length })}</p>`;
205
+ return send(reply, s, shell(s, kindName(tx, type, true), [...crumbsBase, [kindName(tx, type, true)]], main));
197
206
  }
198
207
  let editor: Raw;
199
208
  if (newKind && SHARED.includes(newKind)) {
200
209
  const spec = COMPONENTS[newKind];
201
210
  // "Add entry" from a list fills in the list, "Add action" from an automation the automation (and the next sequence)
202
211
  const prefill = newKind === 'list_entry' && req.query.list ? { list_name: String(req.query.list).toUpperCase() } : newKind === 'automation_action' && req.query.automation ? actionPrefill(String(req.query.automation), rows.automation_action) : {};
203
- editor = region(`New ${spec.label.toLowerCase()}`, componentForm(spec, newKind, { seq: 10, ...spec.defaults, ...prefill }, lk, `${BASE}/apps/${a.id}/shared/${newKind}`, s, 'Create'));
212
+ editor = region(tx('sc.new', { what: tx.lang === 'en' ? spec.label.toLowerCase() : kindName(tx, newKind) }), componentForm(spec, newKind, { seq: 10, ...spec.defaults, ...prefill }, lk, `${BASE}/apps/${a.id}/shared/${newKind}`, s, tx('sc.create')));
204
213
  if (newKind === 'template_component') editor = html`${editor}${templateImport(a.id, s)}`;
205
214
  if (newKind === 'rest_source') editor = html`${await catalogPicker(a.id, s)}${editor}`;
206
215
  } else if (selKind && SHARED.includes(selKind)) {
@@ -209,86 +218,86 @@ export async function sharedRoutes(app: FastifyInstance) {
209
218
  // a workflow definition's form edits its development version (src/builder/workflows.ts)
210
219
  const [formSpec, formRow] = row && selKind === 'workflow_definition' ? workflowForm(spec, row) : [spec, row];
211
220
  editor = row
212
- ? region(`${spec.label}: ${spec.summary(row)}`, html`${componentForm(formSpec, selKind, formRow, lk, `${BASE}/apps/${a.id}/shared/${selKind}/${row.id}`, s, 'Save')}
213
- ${selKind === 'report_layout' ? layoutExtras(a.id, row, s) : selKind === 'automation' ? await automationExtras(a.id, row, s, rows.automation_action) : selKind === 'automation_action' ? actionExtras(row, rows.automation) : selKind === 'document_template' ? documentExtras(a.id, row) : selKind === 'workflow_definition' ? await workflowExtras(a.id, row, s, req.query) : selKind === 'rest_module' ? restExtras(a, row) : selKind === 'template_component' ? templateExtras(a.id, row, req.query) : selKind === 'web_credential' ? credentialExtras(a.id, row, s) : selKind === 'rest_source' ? await restSourceExtras(a.id, row, s) : selKind === 'data_load_def' ? dataLoadDefExtras(row) : selKind === 'list' ? listExtras(a.id, row, rows.list_entry) : selKind === 'supporting_script' ? html`<p class="u-mt1"><a class="btn" href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')} Run supporting objects…</a></p>` : ''}
221
+ ? region(`${kindName(tx, selKind)}: ${spec.summary(row)}`, html`${componentForm(formSpec, selKind, formRow, lk, `${BASE}/apps/${a.id}/shared/${selKind}/${row.id}`, s, tx('sc.save'))}
222
+ ${selKind === 'report_layout' ? layoutExtras(a.id, row, s) : selKind === 'automation' ? await automationExtras(a.id, row, s, rows.automation_action) : selKind === 'automation_action' ? actionExtras(row, rows.automation) : selKind === 'document_template' ? documentExtras(a.id, row) : selKind === 'workflow_definition' ? await workflowExtras(a.id, row, s, req.query) : selKind === 'rest_module' ? restExtras(a, row) : selKind === 'template_component' ? templateExtras(a.id, row, req.query) : selKind === 'web_credential' ? credentialExtras(a.id, row, s) : selKind === 'rest_source' ? await restSourceExtras(a.id, row, s) : selKind === 'data_load_def' ? dataLoadDefExtras(row) : selKind === 'list' ? listExtras(a.id, row, rows.list_entry, tx) : selKind === 'supporting_script' ? html`<p class="u-mt1"><a class="btn" href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')} ${tx('sc.run_supporting')}</a></p>` : ''}
214
223
  ${await usedInPanel(a.id, selKind, row)}
215
224
  ${await subscriptionNote(a.id, selKind, row, s)}
216
- <form method="post" action="${BASE}/apps/${a.id}/shared/${selKind}/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="Delete this ${spec.label.toLowerCase()}?">Delete</button></form>`)
217
- : html`<p>Not found.</p>`;
225
+ <form method="post" action="${BASE}/apps/${a.id}/shared/${selKind}/${row.id}/delete" class="danger-zone">${csrf(s)}<button class="btn btn-danger" data-confirm="${tx('sc.delete_confirm', { what: tx.lang === 'en' ? spec.label.toLowerCase() : kindName(tx, selKind) })}">${tx('sc.delete')}</button></form>`)
226
+ : html`<p>${tx('sc.not_found')}</p>`;
218
227
  } else {
219
228
  const accounts = (await owner.query('select username from meta.account where active order by lower(username) limit 2000')).rows;
220
229
  const appHints = (await roleHints([a.id])).get(a.id) ?? [];
221
230
  const groupRoles = (await owner.query('select group_name, role from meta.app_group_role where app_id = $1 order by 1, 2', [a.id])).rows;
222
- const groupMap = html`<h3>Identity-provider groups → roles</h3>
223
- <p class="muted u-mt0">With single sign-on, members of these groups get the role in this app, and may sign in even without being listed above.</p>
231
+ const groupMap = html`<h3>${tx('sc.groups_title')}</h3>
232
+ <p class="muted u-mt0">${tx('sc.groups_help')}</p>
224
233
  ${groupRoles.length
225
234
  ? html`<div class="chips">${groupRoles.map((g) => html`<span class="chip">${g.group_name} → <b>${g.role}</b>
226
- <form method="post" action="${BASE}/apps/${a.id}/groups/delete" class="u-inline">${csrf(s)}<input type="hidden" name="group_name" value="${g.group_name}"><input type="hidden" name="role" value="${g.role}"><button class="link-button" aria-label="Remove mapping ${g.group_name} to ${g.role}">×</button></form></span>`)}</div>`
227
- : html`<p class="muted">No group mappings.</p>`}
235
+ <form method="post" action="${BASE}/apps/${a.id}/groups/delete" class="u-inline">${csrf(s)}<input type="hidden" name="group_name" value="${g.group_name}"><input type="hidden" name="role" value="${g.role}"><button class="link-button" aria-label="${tx('sc.remove_mapping', { group: g.group_name, role: g.role })}">×</button></form></span>`)}</div>`
236
+ : html`<p class="muted">${tx('sc.no_groups')}</p>`}
228
237
  <form method="post" action="${BASE}/apps/${a.id}/groups">${csrf(s)}
229
- <div class="form-grid">${input('group_name', 'Group (as in the token)', '', { required: true, placeholder: 'e.g. hr-managers' })}${input('role', 'Role in this app', '', { required: true, placeholder: 'e.g. manager' })}</div>
230
- <div class="buttons"><button class="btn">Add mapping</button></div>
238
+ <div class="form-grid">${input('group_name', tx('sc.group_label'), '', { required: true, placeholder: tx('sc.group_ph') })}${input('role', tx('sc.role_label'), '', { required: true, placeholder: tx('sc.role_ph') })}</div>
239
+ <div class="buttons"><button class="btn">${tx('sc.add_mapping')}</button></div>
231
240
  </form>`;
232
- editor = region('Access control', html`
241
+ editor = region(tx('sc.access'), html`
233
242
  <form method="post" action="${BASE}/apps/${a.id}/access" class="search u-mwnone u-mb1">${csrf(s)}
234
- ${select('access_control', 'Who may sign in', a.access_control, [
235
- ['assigned', 'Only accounts listed below (role-based access)'],
236
- ['any_user', 'Any active account in the directory'],
243
+ ${select('access_control', tx('sc.who'), a.access_control, [
244
+ ['assigned', tx('sc.who.assigned')],
245
+ ['any_user', tx('sc.who.any')],
237
246
  ])}
238
- <button class="btn u-selfend">Save</button>
247
+ <button class="btn u-selfend">${tx('sc.save')}</button>
239
248
  </form>
240
- <p class="muted">Accounts live in the <a href="${BASE}/users">user directory</a>; here you grant them access to <b>${a.name}</b> and assign roles, which authorization schemes and <code>meta.has_role()</code> check. Role changes apply at the user's next sign-in (their sessions in this app end).</p>
241
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Account</th><th>Roles in this app</th><th>Last sign-in</th><th></th></tr></thead><tbody>
249
+ <p class="muted">${tx.rich('sc.access_help', { directory: html`<a href="${BASE}/users">${tx('sc.directory')}</a>`, app: html`<b>${a.name}</b>`, fn: html`<code>meta.has_role()</code>` })}</p>
250
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('sc.account')}</th><th>${tx('sc.roles_in_app')}</th><th>${tx('sc.last_signin')}</th><th></th></tr></thead><tbody>
242
251
  ${users.length
243
252
  ? users.map((u) => html`<tr>
244
- <td data-label="Account"><a href="${BASE}/users/${u.id}">${u.username}</a>${u.display_name ? html` <span class="muted">${u.display_name}</span>` : ''}${u.active ? '' : html` <b>(inactive)</b>`}</td>
245
- <td data-label="Roles"><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}" class="search roles-form u-m0 u-mwnone">${csrf(s)}
246
- <input name="roles" value="${u.roles.join(', ')}" aria-label="Roles of ${u.username}" placeholder="no roles"><button class="btn">Save</button>
253
+ <td data-label="${tx('sc.account')}"><a href="${BASE}/users/${u.id}">${u.username}</a>${u.display_name ? html` <span class="muted">${u.display_name}</span>` : ''}${u.active ? '' : html` <b>${tx('sc.inactive')}</b>`}</td>
254
+ <td data-label="${tx('sc.roles')}"><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}" class="search roles-form u-m0 u-mwnone">${csrf(s)}
255
+ <input name="roles" value="${u.roles.join(', ')}" aria-label="${tx('sc.roles_of', { user: u.username })}" placeholder="${tx('sc.no_roles')}"><button class="btn">${tx('sc.save')}</button>
247
256
  ${roleHintsHtml(appHints, 'Add')}</form></td>
248
- <td data-label="Last sign-in">${u.last_login_at ? String(u.last_login_at).slice(0, 16) : '—'}</td>
249
- <td data-label=""><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}/revoke">${csrf(s)}<button class="link-button" data-confirm="Revoke ${u.username}'s access to ${a.name}?">Revoke</button></form></td>
257
+ <td data-label="${tx('sc.last_signin')}">${u.last_login_at ? String(u.last_login_at).slice(0, 16) : '—'}</td>
258
+ <td data-label=""><form method="post" action="${BASE}/apps/${a.id}/access/${u.id}/revoke">${csrf(s)}<button class="link-button" data-confirm="${tx('sc.revoke_confirm', { user: u.username, app: a.name })}">${tx('sc.revoke')}</button></form></td>
250
259
  </tr>`)
251
- : html`<tr><td colspan="4" class="empty">No accounts have access yet.</td></tr>`}
260
+ : html`<tr><td colspan="4" class="empty">${tx('sc.no_access')}</td></tr>`}
252
261
  </tbody></table></div>
253
262
  ${groupMap}
254
- <h3>Grant access</h3>
263
+ <h3>${tx('sc.grant')}</h3>
255
264
  <form method="post" action="${BASE}/apps/${a.id}/access">${csrf(s)}
256
265
  <div class="form-grid">
257
- <div class="field"><label class="label" for="f_grant_user">Account</label>
258
- <input id="f_grant_user" name="username" list="accounts-list" required autocomplete="off" placeholder="username">
266
+ <div class="field"><label class="label" for="f_grant_user">${tx('sc.account')}</label>
267
+ <input id="f_grant_user" name="username" list="accounts-list" required autocomplete="off" placeholder="${tx('sc.username_ph')}">
259
268
  <datalist id="accounts-list">${accounts.map((x) => html`<option value="${x.username}"></option>`)}</datalist>
260
- <small class="help">An existing account. <a href="${BASE}/users">Create accounts in Users.</a></small></div>
261
- <div class="field"><label class="label" for="f_roles">Roles</label>
262
- <input id="f_roles" name="roles" placeholder="comma separated, or pick below">
269
+ <small class="help">${tx('sc.existing')} <a href="${BASE}/users">${tx('sc.create_in_users')}</a></small></div>
270
+ <div class="field"><label class="label" for="f_roles">${tx('sc.roles')}</label>
271
+ <input id="f_roles" name="roles" placeholder="${tx('sc.roles_ph')}">
263
272
  ${roleHintsHtml(appHints)}</div>
264
273
  </div>
265
- <div class="buttons"><button class="btn btn-hot">Grant access</button></div>
274
+ <div class="buttons"><button class="btn btn-hot">${tx('sc.grant')}</button></div>
266
275
  </form>`);
267
276
  }
268
277
 
269
278
  const tree = html`<ul class="tree">
270
- <li class="group">Security</li>
271
- <li><a href="${BASE}/apps/${a.id}/shared"${!selKind && !newKind ? raw(' aria-current="page"') : ''}>${icon('users')}<span>Access control</span><span class="kind">${users.length}</span></a></li>
272
- <li class="group">Globalization</li>
273
- <li><a href="${BASE}/apps/${a.id}/globalization">${icon('file')}<span>Translations and text messages</span><span class="kind">${[a.language, ...(a.languages ?? [])].join(', ')}</span></a></li>
274
- <li class="group">Files and plug-ins</li>
275
- <li><a href="${BASE}/apps/${a.id}/static-files">${icon('file')}<span>Static application files</span></a></li>
276
- <li><a href="${BASE}/apps/${a.id}/plugins">${icon('layers')}<span>Plug-ins</span></a></li>
277
- <li class="group">Subscriptions</li>
278
- <li><a href="${BASE}/apps/${a.id}/subscriptions">${icon('layers')}<span>Subscriptions and subscribers</span>${a.app_type && a.app_type !== 'standard' ? html`<span class="kind">${a.app_type}</span>` : ''}</a></li>
279
+ <li class="group">${tx('sc.tree.security')}</li>
280
+ <li><a href="${BASE}/apps/${a.id}/shared"${!selKind && !newKind ? raw(' aria-current="page"') : ''}>${icon('users')}<span>${tx('sc.access')}</span><span class="kind">${users.length}</span></a></li>
281
+ <li class="group">${tx('sc.tree.globalization')}</li>
282
+ <li><a href="${BASE}/apps/${a.id}/globalization">${icon('file')}<span>${tx('sc.tree.translations')}</span><span class="kind">${[a.language, ...(a.languages ?? [])].join(', ')}</span></a></li>
283
+ <li class="group">${tx('sc.tree.files')}</li>
284
+ <li><a href="${BASE}/apps/${a.id}/static-files">${icon('file')}<span>${tx('sc.tree.static')}</span></a></li>
285
+ <li><a href="${BASE}/apps/${a.id}/plugins">${icon('layers')}<span>${tx('sc.item.plug_ins')}</span></a></li>
286
+ <li class="group">${tx('sc.tree.subscriptions')}</li>
287
+ <li><a href="${BASE}/apps/${a.id}/subscriptions">${icon('layers')}<span>${tx('sc.tree.subscribers')}</span>${a.app_type && a.app_type !== 'standard' ? html`<span class="kind">${a.app_type}</span>` : ''}</a></li>
279
288
  ${SHARED.map((kind) => {
280
289
  const spec = COMPONENTS[kind];
281
- return html`<li class="group">${spec.plural}<a href="?new=${kind}" aria-label="Add ${spec.label}">+ Add</a></li>
290
+ return html`<li class="group">${kindName(tx, kind, true)}<a href="?new=${kind}" aria-label="${tx('sc.add_kind', { what: kindName(tx, kind) })}">${tx('sc.add')}</a></li>
282
291
  ${rows[kind].map((r) => html`<li><a href="?c=${kind}-${r.id}"${selKind === kind && selId === String(r.id) ? raw(' aria-current="page"') : ''}>${icon(kind === 'nav_entry' ? (r.icon ?? 'chevron') : spec.icon)}<span>${r.parent_id ? '↳ ' : ''}${spec.summary(r)}</span>${
283
292
  kind === 'nav_entry' && r.target_page ? html`<span class="kind">p${r.target_page}</span>` : kind === 'authz_scheme' ? html`<span class="kind">${r.type}</span>` : kind === 'app_process' ? html`<span class="kind">${r.point}</span>` : kind === 'report_layout' ? html`<span class="kind">${r.paper}${r.is_default ? ' · default' : ''}</span>` : kind === 'automation' ? html`<span class="kind">${r.enabled ? (r.last_status === 'error' || r.last_status === 'warning' ? r.last_status : r.schedule) : 'off'}</span>` : kind === 'web_credential' ? html`<span class="kind">${r.type}${r.secret_enc || !needsSecret(r) ? '' : ' · no secret'}</span>` : kind === 'rest_source' ? html`<span class="kind">${r.method}${r.sync_table ? ' · sync' : ''}</span>` : kind === 'list' ? html`<span class="kind">${r.type}</span>` : kind === 'supporting_script' ? html`<span class="kind">${r.kind}</span>` : kind === 'search_config' ? html`<span class="kind">${r.source_type}</span>` : ''
284
- }</a></li>`)}${kind === 'supporting_script' ? html`<li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')}<span>Review and run…</span></a></li>` : ''}`;
293
+ }</a></li>`)}${kind === 'supporting_script' ? html`<li><a href="${BASE}/apps/${a.id}/supporting-objects">${icon('play')}<span>${tx('sc.review_run')}</span></a></li>` : ''}`;
285
294
  })}
286
295
  </ul>`;
287
296
 
288
297
  const main = html`${appHeader(a, 'shared', s)}
289
298
  <div class="designer"><aside class="region region-standard">${tree}</aside><div>${editor}</div></div>`;
290
299
  const kindShown = selKind && SHARED.includes(selKind) ? selKind : newKind && SHARED.includes(newKind) ? newKind : '';
291
- return send(reply, s, shell(s, 'Shared Components', kindShown ? [...crumbsBase, [COMPONENTS[kindShown].plural, `${BASE}/apps/${a.id}/shared?type=${kindShown}`], [selKind ? 'Edit' : 'Create']] : [...crumbsBase.slice(0, 2), ['Shared Components']], main));
300
+ return send(reply, s, shell(s, 'Shared Components', kindShown ? [...crumbsBase, [kindName(tx, kindShown, true), `${BASE}/apps/${a.id}/shared?type=${kindShown}`], [selKind ? tx('sc.edit') : tx('sc.create')]] : [...crumbsBase.slice(0, 2), ['Shared Components']], main));
292
301
  });
293
302
 
294
303
  app.post(`${BASE}/apps/:id/shared/:kind/:cid?`, async (req: Req, reply) => {
@@ -298,7 +307,7 @@ export async function sharedRoutes(app: FastifyInstance) {
298
307
  if (!SHARED.includes(kind)) return reply.code(404).send('Unknown component type');
299
308
  try {
300
309
  const newId = await saveComponent(kind, 'app_id', id, cid, req.body ?? {});
301
- flash(s, `${COMPONENTS[kind].label} saved.`);
310
+ flash(s, tr(s)('sc.saved', { what: kindName(tr(s), kind) }));
302
311
  return back(reply, s, `${BASE}/apps/${id}/shared?c=${kind}-${newId}`);
303
312
  } catch (e) {
304
313
  flash(s, (e as Error).message, 'error');
@@ -312,7 +321,7 @@ export async function sharedRoutes(app: FastifyInstance) {
312
321
  const { id, kind, cid } = req.params;
313
322
  if (!SHARED.includes(kind)) return reply.code(404).send('Unknown component type');
314
323
  await owner.query(`delete from ${COMPONENTS[kind].table} where id = $1 and app_id = $2`, [cid, id]);
315
- flash(s, `${COMPONENTS[kind].label} deleted.`);
324
+ flash(s, tr(s)('sc.deleted', { what: kindName(tr(s), kind) }));
316
325
  return back(reply, s, `${BASE}/apps/${id}/shared`);
317
326
  });
318
327
 
@@ -324,12 +333,12 @@ export async function sharedRoutes(app: FastifyInstance) {
324
333
  try {
325
334
  if (b.access_control) {
326
335
  await owner.query('update meta.app set access_control = $2 where id = $1', [req.params.id, b.access_control === 'any_user' ? 'any_user' : 'assigned']);
327
- flash(s, 'Access control saved.');
336
+ flash(s, tr(s)('sc.access_saved'));
328
337
  } else {
329
338
  const acc = await owner.one('select id from meta.account where lower(username) = lower($1)', [b.username?.trim() ?? '']);
330
- if (!acc) throw new Error(`There is no account "${b.username}". Create it under Users first.`);
339
+ if (!acc) throw new Error(tr(s)('sc.no_account', { user: b.username }));
331
340
  await grantAccess(req.params.id, acc.id, splitRoles(b.roles));
332
- flash(s, `Access granted to ${b.username}.`);
341
+ flash(s, tr(s)('sc.granted', { user: b.username }));
333
342
  }
334
343
  } catch (e) {
335
344
  flash(s, (e as Error).message, 'error');
@@ -344,7 +353,7 @@ export async function sharedRoutes(app: FastifyInstance) {
344
353
  await owner.query('insert into meta.app_group_role (app_id, group_name, role) values ($1, $2, $3) on conflict do nothing', [
345
354
  req.params.id, b.group_name?.trim(), b.role?.trim().toLowerCase(),
346
355
  ]);
347
- flash(s, 'Group mapping added. It applies at the next sign-in.');
356
+ flash(s, tr(s)('sc.mapping_added'));
348
357
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
349
358
  });
350
359
 
@@ -352,7 +361,7 @@ export async function sharedRoutes(app: FastifyInstance) {
352
361
  const s = await developer(req, reply);
353
362
  if (!s) return;
354
363
  await owner.query('delete from meta.app_group_role where app_id = $1 and group_name = $2 and role = $3', [req.params.id, req.body?.group_name, req.body?.role]);
355
- flash(s, 'Group mapping removed. It applies at the next sign-in.');
364
+ flash(s, tr(s)('sc.mapping_removed'));
356
365
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
357
366
  });
358
367
 
@@ -360,7 +369,7 @@ export async function sharedRoutes(app: FastifyInstance) {
360
369
  const s = await developer(req, reply);
361
370
  if (!s) return;
362
371
  await grantAccess(req.params.id, req.params.accountId, splitRoles(req.body?.roles));
363
- flash(s, 'Roles saved. They apply at the next sign-in.');
372
+ flash(s, tr(s)('sc.roles_saved'));
364
373
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
365
374
  });
366
375
 
@@ -369,7 +378,7 @@ export async function sharedRoutes(app: FastifyInstance) {
369
378
  if (!s) return;
370
379
  await owner.query('delete from meta.app_access where app_id = $1 and account_id = $2', [req.params.id, req.params.accountId]);
371
380
  await endSessions(req.params.accountId, req.params.id);
372
- flash(s, 'Access revoked.');
381
+ flash(s, tr(s)('sc.revoked'));
373
382
  return back(reply, s, `${BASE}/apps/${req.params.id}/shared`);
374
383
  });
375
384