pgkiln 0.83.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 (126) hide show
  1. package/db/migrations/154_notifications.sql +186 -0
  2. package/docs/apex-feature-parity.md +3 -2
  3. package/docs/guide/03-builder.md +16 -5
  4. package/docs/guide/04-pages-and-regions.md +42 -0
  5. package/docs/guide/06-processing.md +22 -1
  6. package/docs/guide/07-dynamic-actions.md +1 -0
  7. package/docs/guide/09-reference.md +15 -0
  8. package/docs/guide/11-from-apex.md +1 -0
  9. package/docs/guide/12-development.md +13 -1
  10. package/package.json +1 -1
  11. package/public/app.css +29 -0
  12. package/public/app.js +78 -0
  13. package/public/sw.js +5 -1
  14. package/src/app.ts +11 -0
  15. package/src/builder/ai-agents.ts +86 -85
  16. package/src/builder/ai-builder.ts +30 -30
  17. package/src/builder/ai.ts +93 -92
  18. package/src/builder/api.ts +47 -47
  19. package/src/builder/app-groups.ts +33 -32
  20. package/src/builder/appfromfile.ts +57 -58
  21. package/src/builder/appsheets.ts +33 -32
  22. package/src/builder/appwizard.ts +65 -65
  23. package/src/builder/arrange.ts +23 -22
  24. package/src/builder/assistant.ts +39 -38
  25. package/src/builder/automations.ts +32 -31
  26. package/src/builder/availability.ts +24 -24
  27. package/src/builder/backups.ts +30 -31
  28. package/src/builder/blueprints.ts +62 -61
  29. package/src/builder/code-editor.ts +1 -1
  30. package/src/builder/components.ts +10 -6
  31. package/src/builder/create-page.ts +52 -50
  32. package/src/builder/cross-app.ts +37 -35
  33. package/src/builder/dataload.ts +2 -2
  34. package/src/builder/datasets.ts +107 -106
  35. package/src/builder/dependencies.ts +20 -19
  36. package/src/builder/designer.ts +36 -36
  37. package/src/builder/diagnostics.ts +58 -57
  38. package/src/builder/features.ts +39 -35
  39. package/src/builder/feedback.ts +23 -20
  40. package/src/builder/find-workspace.ts +28 -24
  41. package/src/builder/form-designer.ts +36 -35
  42. package/src/builder/gallery.ts +25 -29
  43. package/src/builder/globalization.ts +50 -49
  44. package/src/builder/i18n/de.ts +3461 -0
  45. package/src/builder/i18n/en.ts +3461 -0
  46. package/src/builder/i18n/es.ts +3461 -0
  47. package/src/builder/i18n/fr.ts +3461 -0
  48. package/src/builder/i18n/it.ts +3461 -0
  49. package/src/builder/i18n/ja.ts +3461 -0
  50. package/src/builder/i18n/ko.ts +3461 -0
  51. package/src/builder/i18n/nl.ts +3461 -0
  52. package/src/builder/i18n/pt.ts +3461 -0
  53. package/src/builder/i18n/zh-cn.ts +3461 -0
  54. package/src/builder/i18n/zh-tw.ts +3461 -0
  55. package/src/builder/i18n.ts +17 -3
  56. package/src/builder/json-sources.ts +63 -62
  57. package/src/builder/layouts.ts +12 -11
  58. package/src/builder/locks.ts +29 -28
  59. package/src/builder/login-page.ts +26 -25
  60. package/src/builder/mail.ts +148 -147
  61. package/src/builder/newapp.ts +15 -12
  62. package/src/builder/notification-settings.ts +27 -0
  63. package/src/builder/plugins.ts +47 -44
  64. package/src/builder/process-jobs.ts +11 -10
  65. package/src/builder/pwa.ts +22 -21
  66. package/src/builder/remote-databases.ts +55 -56
  67. package/src/builder/report-settings.ts +1 -1
  68. package/src/builder/reporter.ts +43 -42
  69. package/src/builder/rest-catalogs.ts +87 -86
  70. package/src/builder/rest.ts +9 -8
  71. package/src/builder/routes.ts +30 -27
  72. package/src/builder/search.ts +14 -13
  73. package/src/builder/static-files.ts +31 -32
  74. package/src/builder/subscriptions.ts +47 -45
  75. package/src/builder/supporting.ts +23 -18
  76. package/src/builder/team.ts +179 -179
  77. package/src/builder/telemetry.ts +59 -62
  78. package/src/builder/templates.ts +66 -65
  79. package/src/builder/themeroller.ts +60 -63
  80. package/src/builder/ui-defaults.ts +21 -20
  81. package/src/builder/ui.ts +2 -1
  82. package/src/builder/upgrade.ts +25 -24
  83. package/src/builder/utilities.ts +48 -47
  84. package/src/builder/vector-providers.ts +67 -66
  85. package/src/builder/websources.ts +43 -42
  86. package/src/builder/wizards.ts +95 -89
  87. package/src/builder/workflow-migrate.ts +30 -31
  88. package/src/builder/workflows.ts +40 -39
  89. package/src/builder/workingcopies.ts +41 -49
  90. package/src/builder/workspace-backups.ts +21 -20
  91. package/src/builder/workspace-mail.ts +17 -16
  92. package/src/builder/workspace-resources.ts +104 -112
  93. package/src/builder/workspace-transfer.ts +34 -26
  94. package/src/cli/replace.ts +2 -0
  95. package/src/i18n/ar.ts +9 -0
  96. package/src/i18n/cs.ts +9 -0
  97. package/src/i18n/da.ts +9 -0
  98. package/src/i18n/de.ts +9 -0
  99. package/src/i18n/el.ts +9 -0
  100. package/src/i18n/es.ts +9 -0
  101. package/src/i18n/fi.ts +9 -0
  102. package/src/i18n/fr.ts +9 -0
  103. package/src/i18n/he.ts +9 -0
  104. package/src/i18n/it.ts +9 -0
  105. package/src/i18n/ja.ts +9 -0
  106. package/src/i18n/ko.ts +9 -0
  107. package/src/i18n/nb.ts +9 -0
  108. package/src/i18n/pl.ts +9 -0
  109. package/src/i18n/pt.ts +9 -0
  110. package/src/i18n/ru.ts +9 -0
  111. package/src/i18n/sv.ts +9 -0
  112. package/src/i18n/tr.ts +9 -0
  113. package/src/i18n/uk.ts +9 -0
  114. package/src/i18n/zh.ts +9 -0
  115. package/src/i18n.ts +18 -0
  116. package/src/metadata.ts +9 -3
  117. package/src/runtime/engine.ts +2 -1
  118. package/src/runtime/feedback.ts +1 -1
  119. package/src/runtime/lists.ts +43 -11
  120. package/src/runtime/notifications.ts +237 -0
  121. package/src/runtime/processes.ts +45 -1
  122. package/src/runtime/regions.ts +11 -15
  123. package/src/runtime/render.ts +12 -3
  124. package/src/runtime/routes.ts +12 -4
  125. package/src/runtime/signin.ts +2 -1
  126. package/src/session.ts +13 -4
@@ -7,6 +7,7 @@ import { BASE_STYLES, NEW_APP_BASE } from '../runtime/styles.ts';
7
7
  import { LANGUAGE_NAMES } from '../i18n.ts';
8
8
  import { isIcon } from '../icons.ts';
9
9
  import { secretKeyConfigured } from '../secrets.ts';
10
+ import { scopedTx as tx } from './i18n.ts';
10
11
 
11
12
  /** (097) The icon of a new application's home page when the developer chose none. */
12
13
  export const DEFAULT_APP_ICON = 'layers';
@@ -64,6 +65,8 @@ export const FEATURES: [keyof Features, string, string, boolean][] = [
64
65
  ['pwa', 'Install as an app (PWA)', 'Installable on phones and desktops, with offline pages.', false],
65
66
  ['push', 'Push notifications', 'Notifications to the users\' devices (with Install as an app).', false],
66
67
  ];
68
+ /** A feature's label and help in the builder language (FEATURES stays English). */
69
+ export const featureText = (k: string, label: string, help: string): [string, string] => [tx.dyn(`nwa.f.${k}`, label), tx.dyn(`nwa.fh.${k}`, help)];
67
70
  /** The features ticked on a form (a form without them: the defaults). */
68
71
  export const featuresFrom = (b: Record<string, unknown>, posted: boolean): Features =>
69
72
  Object.fromEntries(FEATURES.map(([k, , , on]) => [k, posted ? b[`f_${k}`] === 'true' : on])) as unknown as Features;
@@ -122,7 +125,7 @@ async function applyFeatures(c: Client, appId: number, f: Partial<Features>, not
122
125
  if (f.pwa || f.push) await c.query('update meta.app set pwa = true where id = $1', [appId]);
123
126
  if (f.push) {
124
127
  if (secretKeyConfigured()) await c.query('update meta.app set pwa_push = true where id = $1', [appId]);
125
- else notes.push('Push notifications are off: they need PGKILN_SECRET_KEY in the server\'s environment.');
128
+ else notes.push(tx('nwa.push_off'));
126
129
  }
127
130
  }
128
131
 
@@ -214,19 +217,19 @@ export const reservedSchema = (schema: string) => /^pg_/i.test(schema) || ['meta
214
217
 
215
218
  /** Validate the form (outside the transaction: the password check may look up the account). Throws an Error with a message for the developer. */
216
219
  export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<CheckedApp> {
217
- if (!(workspaceId > 0)) throw new Error('You are not a developer of any workspace yet: ask an administrator to add you to one.');
220
+ if (!(workspaceId > 0)) throw new Error(tx('nwa.no_workspace'));
218
221
  const alias = (b.alias ?? '').trim().toLowerCase();
219
- if (!/^[a-z][a-z0-9_-]*$/.test(alias) || alias.length > 50) throw new Error('The alias must start with a letter and contain only a-z, 0-9, _ and - (at most 50 characters).');
222
+ if (!/^[a-z][a-z0-9_-]*$/.test(alias) || alias.length > 50) throw new Error(tx('nwa.bad_alias'));
220
223
  const name = (b.name ?? '').trim();
221
- if (!name) throw new Error('Enter a name for the application.');
224
+ if (!name) throw new Error(tx('nwa.enter_name'));
222
225
  const schema = (b.schema ?? '').trim() || alias.replace(/-/g, '_');
223
- if (reservedSchema(schema) || schema.length > 63) throw new Error(`The schema ${schema} can't be the parsing schema of an application.`);
226
+ if (reservedSchema(schema) || schema.length > 63) throw new Error(tx('nwa.bad_schema', { schema }));
224
227
  const authentication = (b.authentication ?? '').trim() || 'app_users';
225
228
  let adminUser: string | null = null;
226
229
  let adminPassword: string | null = null;
227
230
  if (authentication !== 'none') {
228
231
  adminUser = (b.admin_user ?? '').trim();
229
- if (!adminUser) throw new Error('Apps with a login page need a first user.');
232
+ if (!adminUser) throw new Error(tx('nwa.first_user'));
230
233
  const known = await owner.one('select 1 from meta.account where lower(username) = lower($1)', [adminUser]);
231
234
  const problem = known ? null : await passwordProblem(b.admin_password);
232
235
  if (problem) throw new Error(problem);
@@ -234,11 +237,11 @@ export async function checkNewApp(b: NewAppInput, workspaceId: number): Promise<
234
237
  }
235
238
  const description = (b.description ?? '').trim().slice(0, 300) || null;
236
239
  const appIcon = (b.icon ?? '').trim();
237
- if (appIcon && !isIcon(appIcon)) throw new Error('Choose one of the icons in the list.');
240
+ if (appIcon && !isIcon(appIcon)) throw new Error(tx('nwa.choose_icon'));
238
241
  const base = b.base === 'lavender' || b.base === 'standard' ? b.base : 'iris';
239
242
  const accent = /^#[0-9a-f]{6}$/i.test(b.accent ?? '') && b.accent!.toLowerCase() !== BASE_STYLES[base].accent ? b.accent!.toLowerCase() : null;
240
243
  const language = (b.language ?? '').trim() || 'en';
241
- if (!(language in LANGUAGE_NAMES)) throw new Error('Choose one of the languages in the list.');
244
+ if (!(language in LANGUAGE_NAMES)) throw new Error(tx('nwa.choose_language'));
242
245
  return {
243
246
  name, alias, schema, role: `app_${alias.replace(/-/g, '_')}`, authentication, adminUser, adminPassword, workspaceId, description, icon: appIcon || null,
244
247
  base, topNav: b.nav === 'top', accent, language,
@@ -257,13 +260,13 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
257
260
  if (isolated) {
258
261
  // (a taken alias fails below, with its own message)
259
262
  const aliasTaken = (await c.query('select 1 from meta.app where alias = $1', [a.alias])).rowCount;
260
- if (roleExists && !aliasTaken) throw new Error(`The database role ${a.role} exists already: choose another alias.`);
263
+ if (roleExists && !aliasTaken) throw new Error(tx('nwa.role_exists', { role: a.role }));
261
264
  const foreign = await c.query(
262
265
  `select 1 from pg_namespace n where n.nspname = $1
263
266
  and n.nspowner::regrole::text not in (select role_name from meta.workspace_role where workspace_id = $2)`,
264
267
  [a.schema, a.workspaceId],
265
268
  );
266
- if (foreign.rowCount) throw new Error(`The schema ${a.schema} exists already and doesn't belong to this workspace: choose another schema.`);
269
+ if (foreign.rowCount) throw new Error(tx('nwa.schema_foreign', { schema: a.schema }));
267
270
  }
268
271
  if (!roleExists) await c.query(`create role ${R} nologin`);
269
272
  await c.query(`create schema if not exists ${S}${isolated ? ` authorization ${R}` : ''}`);
@@ -318,7 +321,7 @@ export async function createApp(c: Client, a: CheckedApp): Promise<{ id: number;
318
321
  */
319
322
  export async function startFromBoilerplate(c: Client, appId: number, a: CheckedApp, boilerplateId: number) {
320
323
  const bp = (await c.query<{ alias: string }>(`select alias from meta.app where id = $1 and app_type = 'boilerplate'`, [boilerplateId])).rows[0];
321
- if (!bp) throw new Error('Choose a boilerplate application (an application of type Boilerplate).');
324
+ if (!bp) throw new Error(tx('nwa.choose_boilerplate'));
322
325
  const doc = (await c.query('select meta.export_app($1) as d', [bp.alias])).rows[0].d;
323
326
  await replaceApp(c, doc, a.alias, { backup: null });
324
327
  await c.query(`update meta.app set name = $2, db_role = $3, authentication = $4, app_type = 'standard', api_role = null, updated_at = now() where id = $1`, [
@@ -329,6 +332,6 @@ export async function startFromBoilerplate(c: Client, appId: number, a: CheckedA
329
332
  /** A friendlier message for the errors creating an application can raise. */
330
333
  export function createAppError(e: unknown, alias: string) {
331
334
  const err = e as { code?: string; constraint?: string; message: string };
332
- if (err.code === '23505' && /alias/.test(err.constraint ?? err.message)) return `An application with the alias ${alias} already exists.`;
335
+ if (err.code === '23505' && /alias/.test(err.constraint ?? err.message)) return tx('nwa.alias_taken', { alias });
333
336
  return err.message;
334
337
  }
@@ -0,0 +1,27 @@
1
+ import { owner } from '../db.ts';
2
+ import { html, raw } from '../html.ts';
3
+ import type { BuilderTranslate } from './i18n.ts';
4
+ import { input } from './ui.ts';
5
+
6
+ // (sprint 165, migration 154) Settings → User Interface → Notifications: the notification bell
7
+ // (meta.app.notifications) and live badges (meta.app.live_badges_s, 10 to 3600 seconds or none).
8
+
9
+ /** The fields, in the User Interface tab of the application's settings. */
10
+ export function notificationSettings(a: { notifications?: boolean | null; live_badges_s?: number | null }, tx: BuilderTranslate) {
11
+ return html`<h3 id="notifications">${tx('nfy.heading')}</h3>
12
+ <input type="hidden" name="notification_settings" value="1">
13
+ <div class="field"><label class="check"><input type="checkbox" name="notifications" value="true"${a.notifications ? raw(' checked') : ''}> ${tx('nfy.bell')}</label>
14
+ <small class="help">${tx.code('nfy.bell_help')}</small></div>
15
+ <div class="form-grid">
16
+ ${input('live_badges_s', tx('nfy.live'), a.live_badges_s ?? '', { type: 'number', placeholder: tx('nfy.live_ph'), help: tx('nfy.live_help') })}
17
+ </div>`;
18
+ }
19
+
20
+ /** Save them (only when the form had them); a number outside 10 to 3600 is an error. */
21
+ export async function saveNotificationSettings(appId: string, b: Record<string, string | undefined>, tx: BuilderTranslate) {
22
+ if (b.notification_settings !== '1') return;
23
+ const raw = (b.live_badges_s ?? '').trim();
24
+ const seconds = raw === '' ? null : Number(raw);
25
+ if (seconds !== null && !(Number.isInteger(seconds) && seconds >= 10 && seconds <= 3600)) throw new Error(tx('nfy.live_invalid'));
26
+ await owner.query('update meta.app set notifications = $2, live_badges_s = $3 where id = $1', [appId, b.notifications === 'true', seconds]);
27
+ }
@@ -9,6 +9,7 @@ import { clearCompletions } from './code-editor.ts';
9
9
  import { appOr404 } from './forms.ts';
10
10
  import { resultsHtml, runScripts, type RunResult } from './supporting.ts';
11
11
  import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, type Body, type Req } from './ui.ts';
12
+ import { scopedTx as tx, type BuilderKey } from './i18n.ts';
12
13
 
13
14
  // Shared Components → Plug-ins (migration 069; APEX: plug-ins): import a
14
15
  // plug-in file (format pgkiln-plugin/2), look at what it brings, run its
@@ -20,25 +21,30 @@ import { appHeader, back, BASE, csrf, developer, flash, region, send, shell, typ
20
21
 
21
22
  const MAX_FILE_MB = 10;
22
23
 
23
- const TYPE_LABEL: Record<string, string> = { region: 'Region', item: 'Item', dynamic_action: 'Dynamic action', process: 'Process' };
24
+ const TYPE_KEY: Record<string, BuilderKey> = { region: 'plg.type.region', item: 'plg.type.item', dynamic_action: 'plg.type.dynamic_action', process: 'plg.type.process' };
25
+ const HEADING_KEY: Record<string, BuilderKey> = { region: 'plg.heading.region', item: 'plg.heading.item', dynamic_action: 'plg.heading.dynamic_action', process: 'plg.heading.process' };
26
+ const USE_KEY = { region: 'plg.use.region', item: 'plg.use.item', process: 'plg.use.process', dynamic_action: 'plg.use.dynamic_action' } as const satisfies Record<string, BuilderKey>;
27
+ /** How to use a plug-in of a type in a component: a sentence with `code`. */
28
+ const HOW_KEY: Record<string, BuilderKey> = { region: 'plg.how.region', item: 'plg.how.item', dynamic_action: 'plg.how.dynamic_action', process: 'plg.how.process' };
24
29
 
25
30
  /** Where an application uses each plug-in: "page 3: region Ratings", … */
26
31
  async function usages(appId: number) {
27
- const rows = (await owner.query<{ plugin: string; where: string }>(
28
- `select r.config->>'plugin' as plugin, 'page ' || p.page_no || ': region ' || coalesce(r.title, r.id::text) as where
32
+ const rows = (await owner.query<{ plugin: string; kind: 'region' | 'item' | 'process' | 'dynamic_action'; page_no: number; name: string }>(
33
+ `select r.config->>'plugin' as plugin, 'region' as kind, p.page_no, coalesce(r.title, r.id::text) as name,
34
+ 'page ' || p.page_no || ': region ' || coalesce(r.title, r.id::text) as sort
29
35
  from meta.region r join meta.page p on p.id = r.page_id where p.app_id = $1 and r.type = 'plugin'
30
36
  union all
31
- select i.config->>'plugin', 'page ' || p.page_no || ': item ' || i.name
37
+ select i.config->>'plugin', 'item', p.page_no, i.name, 'page ' || p.page_no || ': item ' || i.name
32
38
  from meta.item i join meta.page p on p.id = i.page_id where p.app_id = $1 and i.type = 'plugin'
33
39
  union all
34
- select x.config->>'plugin', 'page ' || p.page_no || ': process ' || x.name
40
+ select x.config->>'plugin', 'process', p.page_no, x.name, 'page ' || p.page_no || ': process ' || x.name
35
41
  from meta.process x join meta.page p on p.id = x.page_id where p.app_id = $1 and x.type = 'plugin'
36
42
  union all
37
- select trim(d.code), 'page ' || p.page_no || ': dynamic action ' || d.name
43
+ select trim(d.code), 'dynamic_action', p.page_no, d.name, 'page ' || p.page_no || ': dynamic action ' || d.name
38
44
  from meta.dynamic_action d join meta.page p on p.id = d.page_id where p.app_id = $1 and d.action = 'plugin'
39
- order by 2`, [appId])).rows;
45
+ order by 5`, [appId])).rows;
40
46
  const out = new Map<string, string[]>();
41
- for (const r of rows) out.set(r.plugin, [...(out.get(r.plugin) ?? []), r.where]);
47
+ for (const r of rows) out.set(r.plugin, [...(out.get(r.plugin) ?? []), tx(USE_KEY[r.kind], { page: r.page_no, name: r.name })]);
42
48
  return out;
43
49
  }
44
50
 
@@ -55,55 +61,52 @@ async function page(s: Session, a: any, selected: string | undefined, result?: {
55
61
  : [];
56
62
 
57
63
  const detail = p
58
- ? region(`${p.label} (${TYPE_LABEL[p.type]} plug-in)`, html`
64
+ ? region(tx(HEADING_KEY[p.type], { label: p.label }), html`
59
65
  <dl class="props">
60
- <dt>Name</dt><dd><code>${p.name}</code>${p.version ? html` · version ${p.version}` : ''}</dd>
61
- ${p.help ? html`<dt>Help</dt><dd>${p.help}</dd>` : ''}
62
- <dt>Used by</dt><dd>${(used.get(p.name) ?? []).join('; ') || html`<span class="muted">nothing yet</span>`}</dd>
63
- <dt>Files</dt><dd>${p.files.length ? p.files.map((f, i) => html`${i ? ', ' : ''}<a href="${BASE}/apps/${a.id}/static-files?edit=${encodeURIComponent(f)}">${f}</a>${missingFiles.includes(f) ? html` <span class="badge badge-warning">missing</span>` : ''}`) : html`<span class="muted">none</span>`}</dd>
64
- ${p.template_component ? html`<dt>Template</dt><dd>${tc ? html`<a href="${BASE}/apps/${a.id}/shared?c=template_component-${tc.id}">${p.template_component}</a>` : html`${p.template_component} <span class="badge badge-warning">missing</span>`}</dd>` : ''}
65
- ${p.sql_function ? html`<dt>Function</dt><dd><code>${p.sql_function}(attributes jsonb) returns text</code></dd>` : ''}
66
+ <dt>${tx('plg.name')}</dt><dd><code>${p.name}</code>${p.version ? html` · ${tx('plg.version_n', { version: p.version })}` : ''}</dd>
67
+ ${p.help ? html`<dt>${tx('plg.help')}</dt><dd>${p.help}</dd>` : ''}
68
+ <dt>${tx('plg.used_by')}</dt><dd>${(used.get(p.name) ?? []).join('; ') || html`<span class="muted">${tx('plg.nothing_yet')}</span>`}</dd>
69
+ <dt>${tx('plg.files')}</dt><dd>${p.files.length ? p.files.map((f, i) => html`${i ? ', ' : ''}<a href="${BASE}/apps/${a.id}/static-files?edit=${encodeURIComponent(f)}">${f}</a>${missingFiles.includes(f) ? html` <span class="badge badge-warning">${tx('plg.missing')}</span>` : ''}`) : html`<span class="muted">${tx('plg.none')}</span>`}</dd>
70
+ ${p.template_component ? html`<dt>${tx('plg.template')}</dt><dd>${tc ? html`<a href="${BASE}/apps/${a.id}/shared?c=template_component-${tc.id}">${p.template_component}</a>` : html`${p.template_component} <span class="badge badge-warning">${tx('plg.missing')}</span>`}</dd>` : ''}
71
+ ${p.sql_function ? html`<dt>${tx('plg.function')}</dt><dd><code>${p.sql_function}(attributes jsonb) returns text</code></dd>` : ''}
66
72
  </dl>
67
73
  ${p.attributes.length
68
- ? html`<div class="table-wrap"><table class="report"><caption>Attributes</caption><thead><tr><th scope="col">Name</th><th scope="col">Label</th><th scope="col">Type</th><th scope="col">Default</th></tr></thead>
74
+ ? html`<div class="table-wrap"><table class="report"><caption>${tx('plg.attributes')}</caption><thead><tr><th scope="col">${tx('plg.name')}</th><th scope="col">${tx('plg.label')}</th><th scope="col">${tx('plg.type')}</th><th scope="col">${tx('plg.default')}</th></tr></thead>
69
75
  <tbody>${p.attributes.map((x) => html`<tr><td><code>${x.name}</code></td><td>${x.label ?? ''}</td><td>${x.type ?? 'text'}${x.options ? `: ${x.options.join(', ')}` : ''}</td><td>${x.default ?? ''}</td></tr>`)}</tbody></table></div>`
70
76
  : ''}
71
- <p class="muted">Use it as ${p.type === 'dynamic_action'
72
- ? html`a dynamic action with action <code>plugin</code>, Code <code>${p.name}</code> and Plug-in attributes <code>{"attributes": {…}}</code>`
73
- : html`a ${p.type} of type <code>plugin</code> with configuration <code>{"plugin": "${p.name}", "attributes": {…}}</code>`}.</p>
77
+ <p class="muted">${tx.code(HOW_KEY[p.type], { name: p.name })}</p>
74
78
  ${p.install_sql
75
- ? html`<h3>Install SQL</h3><p class="muted">Not run on import. Read it, then run it as the application's database role <code>${a.db_role ?? '(none: the runtime connection)'}</code>, in one transaction.</p>
79
+ ? html`<h3>${tx('plg.install_sql')}</h3><p class="muted">${tx.rich('plg.install_help', { role: html`<code>${a.db_role ?? tx('plg.role_none')}</code>` })}</p>
76
80
  <pre class="source">${p.install_sql}</pre>
77
81
  <form method="post" action="${base}/install" class="u-mt1">${csrf(s)}<input type="hidden" name="name" value="${p.name}">
78
- <button class="btn" data-confirm="Run the install SQL of ${p.label} as ${a.db_role ?? 'the runtime connection'}?">${icon('play')} Run the install SQL</button></form>`
82
+ <button class="btn" data-confirm="${a.db_role ? tx('plg.confirm_install', { label: p.label, role: a.db_role }) : tx('plg.confirm_install_runtime', { label: p.label })}">${icon('play')} ${tx('plg.run_install')}</button></form>`
79
83
  : ''}
80
84
  <div class="buttons u-mt1">
81
- <a class="btn" href="${base}/download?name=${encodeURIComponent(p.name)}">${icon('download')} Download plug-in file</a>
85
+ <a class="btn" href="${base}/download?name=${encodeURIComponent(p.name)}">${icon('download')} ${tx('plg.download')}</a>
82
86
  <form method="post" action="${base}/delete" class="inline">${csrf(s)}<input type="hidden" name="name" value="${p.name}">
83
- <button class="btn btn-danger" data-confirm="Remove the plug-in ${p.label}? Its files and template component stay.">${icon('trash')} Remove</button></form>
87
+ <button class="btn btn-danger" data-confirm="${tx('plg.confirm_remove', { label: p.label })}">${icon('trash')} ${tx('plg.remove')}</button></form>
84
88
  </div>`)
85
89
  : '';
86
90
 
87
91
  return html`${appHeader(a, 'shared', s)}
88
- ${result ? region(`Result: install SQL of ${result.name}`, resultsHtml('install', result.run)) : ''}
92
+ ${result ? region(tx('plg.result', { name: result.name }), resultsHtml('install', result.run)) : ''}
89
93
  ${detail}
90
- ${region('Plug-ins', html`<p class="muted u-mt0">Region, item, dynamic action and process types that bring their own code: JavaScript and CSS as
91
- <a href="${BASE}/apps/${a.id}/static-files">static application files</a> (registered with <code>pgkiln.plugins.register</code>), a template component, a PL/pgSQL function.
92
- A plug-in's code runs with this application's rights: import only plug-ins you trust and read their files.</p>
94
+ ${region(tx('plg.title'), html`<p class="muted u-mt0">${tx.rich('plg.intro', { files: html`<a href="${BASE}/apps/${a.id}/static-files">${tx('plg.static_files')}</a>`, register: html`<code>pgkiln.plugins.register</code>` })}
95
+ ${tx('plg.intro_trust')}</p>
93
96
  ${list.length
94
- ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">Plug-ins</caption>
95
- <thead><tr><th scope="col">Plug-in</th><th scope="col">Type</th><th scope="col">Version</th><th scope="col">Used by</th></tr></thead>
97
+ ? html`<div class="table-wrap"><table class="report"><caption class="sr-only">${tx('plg.title')}</caption>
98
+ <thead><tr><th scope="col">${tx('plg.plugin')}</th><th scope="col">${tx('plg.type')}</th><th scope="col">${tx('plg.version')}</th><th scope="col">${tx('plg.used_by')}</th></tr></thead>
96
99
  <tbody>${list.map((x) => html`<tr><td><a href="${base}?p=${x.name}"${x.name === selected ? raw(' aria-current="page"') : ''}>${x.label}</a> <code class="muted">${x.name}</code></td>
97
- <td>${TYPE_LABEL[x.type]}</td><td>${x.version ?? ''}</td><td>${(used.get(x.name) ?? []).length || ''}</td></tr>`)}</tbody></table></div>`
98
- : html`<p class="muted">No plug-ins yet.</p>`}`)}
99
- ${region('Import a plug-in', html`<form method="post" action="${base}/import" enctype="multipart/form-data">${csrf(s)}
100
- <div class="field"><label class="label" for="f_plugin_file">Plug-in file (<code>pgkiln-plugin/2</code> JSON)</label>
100
+ <td>${tx(TYPE_KEY[x.type])}</td><td>${x.version ?? ''}</td><td>${(used.get(x.name) ?? []).length || ''}</td></tr>`)}</tbody></table></div>`
101
+ : html`<p class="muted">${tx('plg.no_plugins')}</p>`}`)}
102
+ ${region(tx('plg.import_title'), html`<form method="post" action="${base}/import" enctype="multipart/form-data">${csrf(s)}
103
+ <div class="field"><label class="label" for="f_plugin_file">${tx.code('plg.file_label')}</label>
101
104
  <input type="file" id="f_plugin_file" name="file" accept=".json,application/json"></div>
102
- <div class="field" data-wide><label class="label" for="f_plugin_json">…or paste it</label>
105
+ <div class="field" data-wide><label class="label" for="f_plugin_json">${tx('plg.or_paste')}</label>
103
106
  <textarea id="f_plugin_json" name="plugin" class="code" rows="6" spellcheck="false" placeholder='{"format": "pgkiln-plugin/2", "type": "region", "name": …}'></textarea></div>
104
- <div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> Replace a plug-in, files and template component with the same names</label></div>
105
- <div class="buttons"><button class="btn btn-hot">${icon('upload')} Import</button></div>
106
- <small class="help">Template components for older plug-in files (<code>pgkiln-plugin/1</code>) are imported under Template components. <code>examples/plugins/</code> has examples.</small>
107
+ <div class="field"><label class="check"><input type="checkbox" name="replace" value="true"> ${tx('plg.replace')}</label></div>
108
+ <div class="buttons"><button class="btn btn-hot">${icon('upload')} ${tx('plg.import')}</button></div>
109
+ <small class="help">${tx.code('plg.import_help')}</small>
107
110
  </form>`)}`;
108
111
  }
109
112
 
@@ -132,7 +135,7 @@ export async function exportPlugin(appId: number, name: string) {
132
135
  }
133
136
 
134
137
  export async function pluginRoutes(app: FastifyInstance) {
135
- const crumbs = (a: any) => [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Shared Components', `${BASE}/apps/${a.id}/shared`], ['Plug-ins']] as [string, string?][];
138
+ const crumbs = (a: any) => [[tx('rail.app_builder'), BASE], [a.name, `${BASE}/apps/${a.id}`], [tx('app.tab.shared'), `${BASE}/apps/${a.id}/shared`], [tx('plg.title')]] as [string, string?][];
136
139
  const find = async (id: string) => (/^\d+$/.test(id) ? appOr404(id) : undefined);
137
140
 
138
141
  app.get(`${BASE}/apps/:id/plugins`, async (req: Req, reply) => {
@@ -140,7 +143,7 @@ export async function pluginRoutes(app: FastifyInstance) {
140
143
  if (!s) return;
141
144
  const a = await find(req.params.id);
142
145
  if (!a) return reply.code(404).send('Not found');
143
- return send(reply, s, shell(s, 'Plug-ins', crumbs(a), await page(s, a, req.query?.p)));
146
+ return send(reply, s, shell(s, tx('plg.title'), crumbs(a), await page(s, a, req.query?.p)));
144
147
  });
145
148
 
146
149
  app.post(`${BASE}/apps/:id/plugins/import`, async (req: Req, reply) => {
@@ -161,16 +164,16 @@ export async function pluginRoutes(app: FastifyInstance) {
161
164
  try {
162
165
  doc = JSON.parse(text);
163
166
  } catch {
164
- flash(s, text ? 'Import failed: the plug-in file is not valid JSON.' : 'Choose a plug-in file or paste one.', 'error');
167
+ flash(s, text ? tx('plg.not_json') : tx('plg.choose_file'), 'error');
165
168
  return back(reply, s, base);
166
169
  }
167
170
  try {
168
171
  const p = await installPlugin(a.id, doc, req.body?.replace === 'true');
169
172
  await logActivity({ appId: a.id, username: s.username, event: 'plugin', ip: clientIp(req), detail: `builder: imported plug-in ${p.name}` });
170
- flash(s, `Plug-in ${p.label} imported.${p.install_sql ? ' Read its install SQL below before running it.' : ''}`);
173
+ flash(s, tx(p.install_sql ? 'plg.imported_install' : 'plg.imported', { label: p.label }));
171
174
  return back(reply, s, `${base}?p=${p.name}`);
172
175
  } catch (e) {
173
- flash(s, `Import failed: ${(e as Error).message}`, 'error');
176
+ flash(s, tx('plg.import_failed', { message: (e as Error).message }), 'error');
174
177
  return back(reply, s, base);
175
178
  }
176
179
  });
@@ -193,7 +196,7 @@ export async function pluginRoutes(app: FastifyInstance) {
193
196
  const a = await find(req.params.id);
194
197
  if (!a) return reply.code(404).send('Not found');
195
198
  const r = await owner.query('delete from meta.plugin where app_id = $1 and name = $2', [a.id, String(req.body?.name ?? '')]);
196
- flash(s, r.rowCount ? 'Plug-in removed. Its files and template component stay (delete them on their own pages).' : 'No such plug-in.', r.rowCount ? 'ok' : 'error');
199
+ flash(s, r.rowCount ? tx('plg.removed') : tx('plg.no_such'), r.rowCount ? 'ok' : 'error');
197
200
  return back(reply, s, `${BASE}/apps/${a.id}/plugins`);
198
201
  });
199
202
 
@@ -207,6 +210,6 @@ export async function pluginRoutes(app: FastifyInstance) {
207
210
  const run = await runScripts(a, [{ name: p.name, script: p.install_sql }], s.username!);
208
211
  clearCompletions();
209
212
  await logActivity({ appId: a.id, username: s.username, event: 'plugin', ip: clientIp(req), detail: `builder: install SQL of ${p.name} ${run.ok ? 'ran' : 'failed'} (${run.results.length} statement(s))` });
210
- return send(reply, s, shell(s, 'Plug-ins', crumbs(a), await page(s, a, p.name, { name: p.label, run })));
213
+ return send(reply, s, shell(s, tx('plg.title'), crumbs(a), await page(s, a, p.name, { name: p.label, run })));
211
214
  });
212
215
  }
@@ -1,5 +1,6 @@
1
1
  import { owner } from '../db.ts';
2
2
  import { html } from '../html.ts';
3
+ import { scopedTx as tx } from './i18n.ts';
3
4
 
4
5
  // Page designer → a background chain process → "Jobs": the last runs of the
5
6
  // chain for every user (meta.process_job; process-jobs.ts runs them).
@@ -14,17 +15,17 @@ export async function processJobsPanel(processId: number) {
14
15
  [processId],
15
16
  )
16
17
  ).rows;
17
- const off = process.env.BACKGROUND_PROCESSES === 'off' ? html`<p><b>Background processes are off on this server (BACKGROUND_PROCESSES=off).</b></p>` : '';
18
+ const off = process.env.BACKGROUND_PROCESSES === 'off' ? html`<p><b>${tx('pjb.off')}</b></p>` : '';
18
19
  return html`${off}${jobs.length
19
- ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Job</th><th>State</th><th>By</th><th>Queued</th><th>Took</th><th>Steps</th><th>Message</th></tr></thead><tbody>
20
+ ? html`<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('pjb.job')}</th><th>${tx('pjb.state')}</th><th>${tx('pjb.by')}</th><th>${tx('pjb.queued')}</th><th>${tx('pjb.took')}</th><th>${tx('pjb.steps')}</th><th>${tx('pjb.message')}</th></tr></thead><tbody>
20
21
  ${jobs.map((j) => html`<tr>
21
- <td data-label="Job">${j.id}</td>
22
- <td data-label="State"><span class="tag${j.state === 'failed' ? ' tag-error' : ''}">${j.state}</span></td>
23
- <td data-label="By">${j.app_user}</td>
24
- <td data-label="Queued">${when(j.queued_at)}</td>
25
- <td data-label="Took">${j.started_at && j.ended_at ? `${((new Date(j.ended_at).getTime() - new Date(j.started_at).getTime()) / 1000).toFixed(1)} s` : j.started_at ? '…' : ''}</td>
26
- <td data-label="Steps">${j.steps_done}/${j.steps_total}${j.current ? ` (${j.current})` : ''}</td>
27
- <td data-label="Message">${j.error ?? j.message ?? ''}</td></tr>`)}
22
+ <td data-label="${tx('pjb.job')}">${j.id}</td>
23
+ <td data-label="${tx('pjb.state')}"><span class="tag${j.state === 'failed' ? ' tag-error' : ''}">${j.state}</span></td>
24
+ <td data-label="${tx('pjb.by')}">${j.app_user}</td>
25
+ <td data-label="${tx('pjb.queued')}">${when(j.queued_at)}</td>
26
+ <td data-label="${tx('pjb.took')}">${j.started_at && j.ended_at ? `${((new Date(j.ended_at).getTime() - new Date(j.started_at).getTime()) / 1000).toFixed(1)} s` : j.started_at ? '…' : ''}</td>
27
+ <td data-label="${tx('pjb.steps')}">${j.steps_done}/${j.steps_total}${j.current ? ` (${j.current})` : ''}</td>
28
+ <td data-label="${tx('pjb.message')}">${j.error ?? j.message ?? ''}</td></tr>`)}
28
29
  </tbody></table></div>`
29
- : html`<p class="muted">No runs yet.</p>`}`;
30
+ : html`<p class="muted">${tx('pjb.no_runs')}</p>`}`;
30
31
  }
@@ -6,6 +6,7 @@ import { html, raw } from '../html.ts';
6
6
  import { readMultipart } from '../runtime/files.ts';
7
7
  import { back, BASE, csrf, developer, flash, input, type Body, type Req } from './ui.ts';
8
8
  import type { Session } from '../session.ts';
9
+ import { scopedTx as tx } from './i18n.ts';
9
10
 
10
11
  // App → Settings → Progressive Web App: installable, offline pages, the
11
12
  // offline form queue, the icon (src/runtime/pwa.ts serves the rest), and
@@ -36,29 +37,29 @@ export async function pwaSection(a: { id: number; alias: string; pwa: boolean; p
36
37
  html`<div class="field"><label class="check"><input type="checkbox" name="${name}" value="true"${on ? raw(' checked') : ''}> ${label}</label><small class="help">${help}</small></div>`;
37
38
  const stats = a.pwa && a.pwa_push ? await pushStats(a.id) : null;
38
39
  return html`<form method="post" action="${BASE}/apps/${a.id}/pwa" enctype="multipart/form-data">${csrf(s)}
39
- ${check('pwa', 'Installable (Progressive Web App)', a.pwa, 'Phones and computers can install the app: its own icon, full screen, and the app works on with a bad or no connection.')}
40
+ ${check('pwa', tx('pwa.installable'), a.pwa, tx('pwa.installable_help'))}
40
41
  <div class="form-grid">
41
- ${input('pwa_short_name', 'Name under the icon', a.pwa_short_name ?? '', { placeholder: 'at most 12 characters work best' })}
42
+ ${input('pwa_short_name', tx('pwa.short_name'), a.pwa_short_name ?? '', { placeholder: tx('pwa.short_name_ph') })}
42
43
  </div>
43
- ${check('pwa_offline_pages', 'Keep visited pages on the device for offline use', a.pwa_offline_pages, 'The pages a user opened are shown when there is no connection. They contain personal data: they are removed when someone signs in or out on the device.')}
44
- ${check('pwa_offline_submit', 'Keep forms sent without a connection, and send them later', a.pwa_offline_submit, 'For field work: forms (with photos) are kept on the device and sent when the connection is back, under the same user, at most once.')}
45
- ${check('pwa_push', 'Push notifications', a.pwa_push, 'Users turn them on per device under My account (or with the dynamic action push_subscribe); application code sends them with meta.send_push(user, title, body, page, items) or a send_push process. On iPhone and iPad only after the app was added to the home screen. Needs PGKILN_SECRET_KEY: the key that signs them is stored encrypted.')}
46
- <div class="field" data-wide><label class="label" for="f_pwa_icon">Icon (PNG, square, at least 512 × 512)</label>
47
- <div class="u-row">${a.has_icon || a.pwa ? html`<img class="pwa-icon-preview" src="/a/${a.alias}/icon-192.png" alt="The app's icon" width="48" height="48">` : ''}
44
+ ${check('pwa_offline_pages', tx('pwa.offline_pages'), a.pwa_offline_pages, tx('pwa.offline_pages_help'))}
45
+ ${check('pwa_offline_submit', tx('pwa.offline_submit'), a.pwa_offline_submit, tx('pwa.offline_submit_help'))}
46
+ ${check('pwa_push', tx('pwa.push'), a.pwa_push, tx('pwa.push_help'))}
47
+ <div class="field" data-wide><label class="label" for="f_pwa_icon">${tx('pwa.icon')}</label>
48
+ <div class="u-row">${a.has_icon || a.pwa ? html`<img class="pwa-icon-preview" src="/a/${a.alias}/icon-192.png" alt="${tx('pwa.icon_alt')}" width="48" height="48">` : ''}
48
49
  <input type="file" id="f_pwa_icon" name="icon" accept="image/png"></div>
49
- <small class="help">${a.has_icon ? html`An icon is stored. <label class="check u-inline"><input type="checkbox" name="remove_icon" value="true"> Remove it</label>` : 'Without one, the app gets a tile with its initial in the accent colour.'}</small></div>
50
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
50
+ <small class="help">${a.has_icon ? html`${tx('pwa.icon_stored')} <label class="check u-inline"><input type="checkbox" name="remove_icon" value="true"> ${tx('pwa.remove_icon')}</label>` : tx('pwa.no_icon')}</small></div>
51
+ <div class="buttons"><button class="btn btn-hot">${tx('common.save')}</button></div>
51
52
  </form>
52
53
  ${stats
53
- ? html`<h3>Push notifications</h3>
54
- <p class="muted">${stats.devices} device(s) of ${stats.users} user(s) receive notifications. Last 7 days: ${stats.sent} sent, ${stats.no_device} to users without a device, ${stats.failed} failed${stats.waiting ? `, ${stats.waiting} waiting` : ''}.</p>
54
+ ? html`<h3>${tx('pwa.push')}</h3>
55
+ <p class="muted">${tx('pwa.stats_devices', { devices: stats.devices, users: stats.users })} ${tx(stats.waiting ? 'pwa.stats_week_waiting' : 'pwa.stats_week', { sent: stats.sent, no_device: stats.no_device, failed: stats.failed, waiting: stats.waiting })}</p>
55
56
  <form method="post" action="${BASE}/apps/${a.id}/pwa/push-test" class="u-row">${csrf(s)}
56
- ${input('push_user', 'Send a test notification to', s.username ?? '', { placeholder: 'user name' })}
57
- <div class="buttons"><button class="btn">Send test</button></div>
57
+ ${input('push_user', tx('pwa.test_to'), s.username ?? '', { placeholder: tx('pwa.user_name') })}
58
+ <div class="buttons"><button class="btn">${tx('pwa.send_test')}</button></div>
58
59
  </form>
59
60
  <form method="post" action="${BASE}/apps/${a.id}/pwa/push-keys">${csrf(s)}
60
- <div class="buttons"><button class="btn btn-danger">New keys</button></div>
61
- <small class="help">Makes a new key pair (for example after the private key may have leaked). Every device stops receiving notifications until its user turns them on again.</small>
61
+ <div class="buttons"><button class="btn btn-danger">${tx('pwa.new_keys')}</button></div>
62
+ <small class="help">${tx('pwa.new_keys_help')}</small>
62
63
  </form>`
63
64
  : ''}`;
64
65
  }
@@ -83,18 +84,18 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
83
84
  if (file && file.data.length) {
84
85
  const size = file.truncated ? null : pngSize(file.data);
85
86
  if (!size) {
86
- flash(s, file.truncated ? `The icon is larger than ${ICON_MAX_MB} MB.` : 'The icon must be a PNG image.', 'error');
87
+ flash(s, file.truncated ? tx('pwa.icon_too_large', { mb: ICON_MAX_MB }) : tx('pwa.icon_not_png'), 'error');
87
88
  return back(reply, s, target);
88
89
  }
89
90
  if (size.width !== size.height || size.width < 512) {
90
- flash(s, `The icon must be square and at least 512 × 512 pixels (this one is ${size.width} × ${size.height}).`, 'error');
91
+ flash(s, tx('pwa.icon_size', { width: size.width, height: size.height }), 'error');
91
92
  return back(reply, s, target);
92
93
  }
93
94
  icon = file.data;
94
95
  }
95
96
  const push = b.pwa_push === 'true';
96
97
  if (push && !secretKeyConfigured()) {
97
- flash(s, 'Push notifications need PGKILN_SECRET_KEY in the server\'s environment (the key that signs them is stored encrypted).', 'error');
98
+ flash(s, tx('pwa.push_needs_key'), 'error');
98
99
  return back(reply, s, target);
99
100
  }
100
101
  await owner.query(
@@ -103,7 +104,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
103
104
  [id, b.pwa === 'true', (b.pwa_short_name ?? '').trim().slice(0, 30) || null, b.pwa_offline_pages === 'true', b.pwa_offline_submit === 'true', icon ?? null, icon !== undefined, push],
104
105
  );
105
106
  if (push) await appPublicKey(Number(id));
106
- flash(s, push && b.pwa !== 'true' ? 'Saved. Push notifications work only when the app is installable.' : 'Progressive Web App settings saved.');
107
+ flash(s, push && b.pwa !== 'true' ? tx('pwa.saved_not_installable') : tx('pwa.saved'));
107
108
  return back(reply, s, target);
108
109
  });
109
110
 
@@ -126,7 +127,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
126
127
  return back(reply, s, target);
127
128
  }
128
129
  const devices = (await owner.one<{ n: number }>('select count(*)::int as n from meta.push_subscription where app_id = $1 and lower(username) = lower($2)', [a.id, user]))!.n;
129
- flash(s, devices ? `Test notification queued for ${devices} device(s) of ${user}.` : `${user} has not turned on notifications on any device (My account → Notifications).`, devices ? 'ok' : 'error');
130
+ flash(s, devices ? tx('pwa.test_queued', { n: devices, user }) : tx('pwa.test_no_device', { user }), devices ? 'ok' : 'error');
130
131
  return back(reply, s, target);
131
132
  });
132
133
 
@@ -136,7 +137,7 @@ export async function pwaBuilderRoutes(app: FastifyInstance) {
136
137
  const id = req.params.id;
137
138
  if (!/^\d+$/.test(id) || !(await owner.one('select 1 from meta.app where id = $1', [id]))) return reply.code(404).send('Not found');
138
139
  await newAppKeys(Number(id));
139
- flash(s, 'New keys made. Users have to turn notifications on again on each device.');
140
+ flash(s, tx('pwa.keys_made'));
140
141
  return back(reply, s, `${BASE}/apps/${id}/settings?tab=pwa`);
141
142
  });
142
143
  }