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.
@@ -408,12 +408,13 @@ export async function builderRoutes(app: FastifyInstance) {
408
408
  app.get(`${BASE}/apps/:id/settings`, async (req: Req, reply) => {
409
409
  const s = await developer(req, reply);
410
410
  if (!s) return;
411
+ const tx = tr(s);
411
412
  const a = await appOr404(req.params.id);
412
413
  if (!a) return reply.code(404).send('Not found');
413
414
  const providers = (await owner.query('select name, display_name, enabled from meta.auth_provider order by display_name')).rows;
414
415
  const directories = (await owner.query('select name, display_name, enabled from meta.ldap_directory order by display_name')).rows;
415
416
  const lists = (await owner.query('select name from meta.list where app_id = $1 order by name', [a.id])).rows.map((r) => r.name as string);
416
- const listChoices = (v: string | null, none: string): [string, string][] => [['', none], ...lists.map((n): [string, string] => [n, n]), ...(v && !lists.includes(v) ? [[v, `${v} (missing!)`] as [string, string]] : [])];
417
+ const listChoices = (v: string | null, none: string): [string, string][] => [['', none], ...lists.map((n): [string, string] => [n, n]), ...(v && !lists.includes(v) ? [[v, tx('st.missing', { name: v })] as [string, string]] : [])];
417
418
  // (sprint 74) tabs like APEX's Edit Application Definition and Security Attributes: every tab of the
418
419
  // main form is in the page (the others hidden), so Save keeps all of them; without JavaScript the tabs are links
419
420
  const tab = SETTINGS_TABS.some(([k]) => k === req.query?.tab) ? String(req.query!.tab) : 'definition';
@@ -421,123 +422,123 @@ export async function builderRoutes(app: FastifyInstance) {
421
422
  const end = raw('</section>');
422
423
  const authPart = (k: string) => raw(`<div class="auth-part" data-auth="${k}"${a.authentication === k ? '' : ' hidden'}>`);
423
424
  const main = html`${appHeader(a, 'settings', s)}
424
- <nav class="settings-tabs" aria-label="Settings">${SETTINGS_TABS.map(([k, label]) => html`<a href="?tab=${k}" data-tab-link="${k}"${k === tab ? raw(' aria-current="page"') : ''}>${label}</a>`)}</nav>
425
+ <nav class="settings-tabs" aria-label="${tx('st.settings')}">${SETTINGS_TABS.map(([k, label]) => html`<a href="?tab=${k}" data-tab-link="${k}"${k === tab ? raw(' aria-current="page"') : ''}>${tx.dyn(`st.tab.${k}`, label)}</a>`)}</nav>
425
426
  <div class="settings-body">
426
427
  <form method="post" action="${BASE}/apps/${a.id}/settings" class="settings-form">${csrf(s)}<input type="hidden" name="tab" value="${tab}" data-settings-tab>
427
- ${panel('definition')}${region('Definition', html`
428
+ ${panel('definition')}${region(tx('st.tab.definition'), html`
428
429
  <div class="form-grid">
429
- ${input('name', 'Name', a.name, { required: true })}
430
- ${input('alias', 'Alias', a.alias, { required: true })}
431
- ${input('version', 'Version', a.version ?? '', { placeholder: 'Release 1.0', help: 'Shown in the footer of the application\'s pages; empty: none.' })}
432
- ${input('home_page', 'Home page', a.home_page, { type: 'number' })}
433
- ${select('app_type', 'Application type', a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, APP_TYPE_LABELS[t]]),
434
- 'Theme and library applications offer their theme or shared components to other applications (Shared Components → Subscriptions); Create application can start from a boilerplate application.')}
430
+ ${input('name', tx('st.name'), a.name, { required: true })}
431
+ ${input('alias', tx('st.alias'), a.alias, { required: true })}
432
+ ${input('version', tx('st.version'), a.version ?? '', { placeholder: tx('st.version_ph'), help: tx('st.version_help') })}
433
+ ${input('home_page', tx('st.home_page'), a.home_page, { type: 'number' })}
434
+ ${select('app_type', tx('st.app_type'), a.app_type ?? 'standard', APP_TYPES.map((t): [string, string] => [t, tx.dyn(`st.type.${t}`, APP_TYPE_LABELS[t])]),
435
+ tx('st.app_type_help'))}
435
436
  </div>`)}${end}
436
- ${panel('security')}${region('Authentication', html`
437
+ ${panel('security')}${region(tx('st.authentication'), html`
437
438
  <div class="form-grid">
438
- ${select('authentication', 'Authentication', a.authentication, [['app_users', 'App users (login page)'], ['header', 'HTTP header (reverse proxy)'], ['database', 'Database accounts (PostgreSQL roles)'], ['custom', 'Custom (a PL/pgSQL function)'], ['none', 'None (public)']])}
439
- ${input('db_role', 'Database role (parsing schema)', a.db_role, { help: 'All application SQL runs as this role (SET LOCAL ROLE), so grants and row level security apply. Leave empty only for trusted internal apps.' })}
440
- <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="debug" value="true"${a.debug ? raw(' checked') : ''}> Debug mode</label>
441
- <small class="help">Shows database error details to end users. Development only.</small></div>
442
- <div class="field"><span class="label">Debug messages</span><span>${a.debug_level ? html`<b>on, level ${a.debug_level}</b>` : 'off'} · <a href="${BASE}/apps/${a.id}/debug">Debug messages</a></span>
443
- <small class="help">Records every request's steps with timings and the messages of <code>meta.debug(level, text)</code>, kept ${a.debug_retention_days} days.</small></div>
439
+ ${select('authentication', tx('st.authentication'), a.authentication, (['app_users', 'header', 'database', 'custom', 'none'] as const).map((k): [string, string] => [k, tx(`st.auth.${k}`)]))}
440
+ ${input('db_role', tx('st.db_role'), a.db_role, { help: tx('st.db_role_help') })}
441
+ <div class="field"><span class="label" aria-hidden="true"></span><label class="check"><input type="checkbox" name="debug" value="true"${a.debug ? raw(' checked') : ''}> ${tx('st.debug')}</label>
442
+ <small class="help">${tx('st.debug_help')}</small></div>
443
+ <div class="field"><span class="label">${tx('st.debug_msgs')}</span><span>${a.debug_level ? html`<b>${tx('st.debug_on', { level: a.debug_level })}</b>` : tx('st.off')} · <a href="${BASE}/apps/${a.id}/debug">${tx('st.debug_msgs')}</a></span>
444
+ <small class="help">${tx.rich('st.debug_msgs_help', { fn: html`<code>meta.debug(level, text)</code>`, days: a.debug_retention_days })}</small></div>
444
445
  </div>
445
- ${authPart('header')}<h3>HTTP header authentication</h3>
446
- <p class="muted">Only used when Authentication is "HTTP header". A reverse proxy or single sign-on gateway signs users in and passes the user name in a header; pgkiln trusts it only from the proxy addresses in <code>PGKILN_AUTH_HEADER_PROXIES</code>${headerProxiesConfigured() ? '' : html` (<b>not set on this server: header sign-in is refused</b>)`}.</p>
446
+ ${authPart('header')}<h3>${tx('st.header_title')}</h3>
447
+ <p class="muted">${tx.rich('st.header_intro', { env: html`<code>PGKILN_AUTH_HEADER_PROXIES</code>` })}${headerProxiesConfigured() ? '' : html` (<b>${tx('st.header_unset')}</b>)`}.</p>
447
448
  <div class="form-grid">
448
- ${input('header_name', 'User name header', a.header_name ?? '', { placeholder: DEFAULT_HEADER, help: 'The request header with the user name (APEX: HTTP Header Variable). Empty: X-Remote-User. A changed or missing header ends the session.' })}
449
- ${input('logout_url', 'Sign-out URL', a.logout_url ?? '', { placeholder: 'e.g. https://sso.example.com/logout', help: 'Where "Sign out" goes after the session ends, usually the proxy\'s own sign-out page. Empty: a "signed out" page.' })}
449
+ ${input('header_name', tx('st.header_name'), a.header_name ?? '', { placeholder: DEFAULT_HEADER, help: tx('st.header_name_help') })}
450
+ ${input('logout_url', tx('st.logout_url'), a.logout_url ?? '', { placeholder: tx('st.logout_url_ph'), help: tx('st.logout_url_help') })}
450
451
  </div>
451
- <div class="field"><label class="check"><input type="checkbox" name="header_auto_create" value="true"${a.header_auto_create ? raw(' checked') : ''}> Create accounts automatically</label>
452
- <small class="help">An unknown user name gets a new account with access to this app. Otherwise the account must exist and have access.</small></div>
453
- </div>${authPart('database')}<h3>Database accounts</h3>
454
- <p class="muted">Only used when Authentication is "Database accounts". Users sign in with a PostgreSQL login role and its password, checked by a short connection to this database as that role. Superusers and pgkiln's own roles are always refused; with neither field set nobody can sign in.</p>
452
+ <div class="field"><label class="check"><input type="checkbox" name="header_auto_create" value="true"${a.header_auto_create ? raw(' checked') : ''}> ${tx('st.header_auto')}</label>
453
+ <small class="help">${tx('st.header_auto_help')}</small></div>
454
+ </div>${authPart('database')}<h3>${tx('st.db_title')}</h3>
455
+ <p class="muted">${tx('st.db_intro')}</p>
455
456
  <div class="form-grid">
456
- ${input('db_auth_roles', 'Allowed roles', (a.db_auth_roles ?? []).join(', '), { placeholder: 'e.g. alice, bob', help: 'Login roles that may sign in, comma separated (exact names).' })}
457
- ${input('db_auth_member_of', 'Or members of role', a.db_auth_member_of ?? '', { placeholder: 'e.g. app_users_group', help: 'Every member of this role may sign in too.' })}
457
+ ${input('db_auth_roles', tx('st.db_roles'), (a.db_auth_roles ?? []).join(', '), { placeholder: tx('st.db_roles_ph'), help: tx('st.db_roles_help') })}
458
+ ${input('db_auth_member_of', tx('st.db_member'), a.db_auth_member_of ?? '', { placeholder: tx('st.db_member_ph'), help: tx('st.db_member_help') })}
458
459
  </div>
459
- </div>${authPart('custom')}<h3>Custom authentication</h3>
460
- <p class="muted">Only used when Authentication is "Custom". Your own PL/pgSQL checks the user name and password on the sign-in form, as the application's database role; the password is passed as a parameter and never logged. Failed attempts are throttled like other sign-ins. With neither field set nobody can sign in.</p>
460
+ </div>${authPart('custom')}<h3>${tx('st.custom_title')}</h3>
461
+ <p class="muted">${tx('st.custom_intro')}</p>
461
462
  <div class="form-grid">
462
- ${input('custom_auth_function', 'Function name', a.custom_auth_function ?? '', { placeholder: 'e.g. app.check_login', help: 'A function (p_username text, p_password text) returns boolean, in lower case, optionally with its schema. The app\'s role needs EXECUTE on it. Takes precedence over the body below.' })}
463
+ ${input('custom_auth_function', tx('st.custom_fn'), a.custom_auth_function ?? '', { placeholder: tx('st.custom_fn_ph'), help: tx('st.custom_fn_help') })}
463
464
  </div>
464
- <div class="field" data-wide><label class="label" for="f_custom_auth_code">Or function body</label>
465
+ <div class="field" data-wide><label class="label" for="f_custom_auth_code">${tx('st.custom_body')}</label>
465
466
  <textarea id="f_custom_auth_code" name="custom_auth_code" class="code" rows="6" spellcheck="false" data-code="plpgsql">${a.custom_auth_code ?? ''}</textarea>
466
- <small class="help">PL/pgSQL with p_username and p_password, returning true for a valid sign-in, e.g. <code>return exists (select 1 from app.users where name = p_username and pw_hash = crypt(p_password, pw_hash));</code></small></div>
467
- <div class="field" data-wide><label class="label" for="f_custom_auth_post_code">Post-authentication code</label>
467
+ <small class="help">${tx.rich('st.custom_body_help', { example: html`<code>return exists (select 1 from app.users where name = p_username and pw_hash = crypt(p_password, pw_hash));</code>` })}</small></div>
468
+ <div class="field" data-wide><label class="label" for="f_custom_auth_post_code">${tx('st.custom_post')}</label>
468
469
  <textarea id="f_custom_auth_post_code" name="custom_auth_post_code" class="code" rows="4" spellcheck="false" data-code="plpgsql">${a.custom_auth_post_code ?? ''}</textarea>
469
- <small class="help">Optional PL/pgSQL run after a successful check, with p_username (and meta.app_user()), e.g. to record the last sign-in. Raising an exception refuses the sign-in. Application processes "after login" run afterwards as usual.</small></div>
470
+ <small class="help">${tx('st.custom_post_help')}</small></div>
470
471
  </div>`)}${end}
471
- ${panel('signin')}${region('Sign-in', html`
472
- <h3>Sign-in methods</h3>
473
- <div class="field"><label class="check"><input type="checkbox" name="local_login" value="true"${a.local_login ? raw(' checked') : ''}> Username and password</label></div>
472
+ ${panel('signin')}${region(tx('st.signin'), html`
473
+ <h3>${tx('st.methods')}</h3>
474
+ <div class="field"><label class="check"><input type="checkbox" name="local_login" value="true"${a.local_login ? raw(' checked') : ''}> ${tx('st.local')}</label></div>
474
475
  ${directories.length
475
- ? directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> Passwords from LDAP: ${d.display_name}${d.enabled ? '' : ' (disabled)'}</label></div>`)
476
- : html`<p class="muted">No LDAP directories configured. <a href="${BASE}/users/directories">Add one</a> to check passwords against LDAP or Active Directory.</p>`}
477
- <small class="help">LDAP passwords are checked by the username and password form, after local accounts, so keep that method on.</small>
478
- <div class="form-grid">${input('session_group', 'Session sharing group', a.session_group ?? '', { placeholder: 'empty: not shared', help: 'Applications with the same group (and the user directory as authentication) share a sign-in: after signing in to one, the others open without a new sign-in, each with its own access check and roles; signing out of one signs out of all (APEX: session sharing). Lower case letters, digits and _.' })}</div>
476
+ ? directories.map((d) => html`<div class="field"><label class="check"><input type="checkbox" name="ldap_directories" value="${d.name}"${a.ldap_directories.includes(d.name) ? raw(' checked') : ''}> ${tx('st.ldap', { name: d.display_name })}${d.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
477
+ : html`<p class="muted">${tx.rich('st.no_ldap', { add: html`<a href="${BASE}/users/directories">${tx('st.add_one')}</a>` })}</p>`}
478
+ <small class="help">${tx('st.ldap_help')}</small>
479
+ <div class="form-grid">${input('session_group', tx('st.session_group'), a.session_group ?? '', { placeholder: tx('st.session_group_ph'), help: tx('st.session_group_help') })}</div>
479
480
  ${await loginSettingsFields(a)}
480
- <div class="form-grid">${input('remember_me_days', '"Keep me signed in" for (days)', a.remember_me_days ?? '', { type: 'number', placeholder: 'empty: not offered', help: 'Offers a checkbox on the sign-in form (APEX: persistent authentication). The browser stays signed in for this many days after the sign-in, also when the session ends; signing out, a new password, deactivation or removed access ends it. 1 to 365.' })}</div>
481
+ <div class="form-grid">${input('remember_me_days', tx('st.remember'), a.remember_me_days ?? '', { type: 'number', placeholder: tx('st.remember_ph'), help: tx('st.remember_help') })}</div>
481
482
  ${providers.length
482
- ? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> Sign in with ${pr.display_name}${pr.enabled ? '' : ' (disabled)'}</label></div>`)
483
- : html`<p class="muted">No identity providers configured. <a href="${BASE}/users/providers">Add one</a> for single sign-on.</p>`}
483
+ ? providers.map((pr) => html`<div class="field"><label class="check"><input type="checkbox" name="sso_providers" value="${pr.name}"${a.sso_providers.includes(pr.name) ? raw(' checked') : ''}> ${tx('st.sso', { name: pr.display_name })}${pr.enabled ? '' : ` ${tx('st.disabled')}`}</label></div>`)
484
+ : html`<p class="muted">${tx.rich('st.no_sso', { add: html`<a href="${BASE}/users/providers">${tx('st.add_one')}</a>` })}</p>`}
484
485
  `)}${end}
485
- ${panel('ui')}${region('User Interface', html`
486
- <h3>Theme</h3>
486
+ ${panel('ui')}${region(tx('st.tab.ui'), html`
487
+ <h3>${tx('st.theme')}</h3>
487
488
  <div class="form-grid">
488
- ${select('base', 'Base style', baseStyleOf(a.theme), [['iris', 'Iris (the default for new applications)'], ['lavender', 'Lavender (Iris until pgkiln 0.41)'], ['standard', 'Standard (pgkiln until 0.28)']], 'Colours, corners, font and shadows of the whole application, light and dark. The colours below override its accent and header.')}
489
- ${input('accent', 'Accent colour', a.theme?.accent ?? BASE_STYLES[baseStyleOf(a.theme)].accent, { type: 'color' })}
490
- ${input('header', 'Header colour', a.theme?.header ?? BASE_STYLES[baseStyleOf(a.theme)].header, { type: 'color' })}
491
- ${(['accent_dark', 'header_dark'] as const).map((k) => html`<div class="field"><label class="label" for="f_${k}">${k === 'accent_dark' ? 'Accent colour in dark mode' : 'Header colour in dark mode'}</label>
489
+ ${select('base', tx('st.base'), baseStyleOf(a.theme), (['iris', 'lavender', 'standard'] as const).map((k): [string, string] => [k, tx(`st.base.${k}`)]), tx('st.base_help'))}
490
+ ${input('accent', tx('st.accent'), a.theme?.accent ?? BASE_STYLES[baseStyleOf(a.theme)].accent, { type: 'color' })}
491
+ ${input('header', tx('st.header_colour'), a.theme?.header ?? BASE_STYLES[baseStyleOf(a.theme)].header, { type: 'color' })}
492
+ ${(['accent_dark', 'header_dark'] as const).map((k) => html`<div class="field"><label class="label" for="f_${k}">${tx(k === 'accent_dark' ? 'st.accent_dark' : 'st.header_dark')}</label>
492
493
  <input id="f_${k}" name="${k}" type="color" value="${a.theme?.[k] ?? (k === 'accent_dark' ? '#a59cff' : '#0d0c1a')}">
493
- <label class="check"><input type="checkbox" name="${k}_own" value="true"${a.theme?.[k] ? raw(' checked') : ''}> Use this colour (else the base style's dark palette)</label></div>`)}
494
- ${select('nav', 'Navigation menu', a.theme?.nav ?? 'side', [['side', 'Side (collapsible)'], ['top', 'Top bar']], 'On tablets and phones the menu is always a drawer.')}
495
- ${select('nav_list', 'Navigation menu list', a.nav_list ?? '', listChoices(a.nav_list, '- the navigation entries -'), 'A list (Shared Components → Lists) shown as the navigation menu instead of the navigation entries.')}
496
- ${select('navbar_list', 'Navigation bar list', a.navbar_list ?? '', listChoices(a.navbar_list, '- none -'), 'A list shown as links in the header, next to the user menu.')}
497
- ${select('mode', 'Theme style', a.theme?.mode ?? 'auto', [['auto', 'Automatic (light or dark, following the device)'], ['light', 'Light'], ['dark', 'Dark']])}
494
+ <label class="check"><input type="checkbox" name="${k}_own" value="true"${a.theme?.[k] ? raw(' checked') : ''}> ${tx('st.own_colour')}</label></div>`)}
495
+ ${select('nav', tx('st.nav'), a.theme?.nav ?? 'side', [['side', tx('st.nav.side')], ['top', tx('st.nav.top')]], tx('st.nav_help'))}
496
+ ${select('nav_list', tx('st.nav_list'), a.nav_list ?? '', listChoices(a.nav_list, tx('st.nav_entries')), tx('st.nav_list_help'))}
497
+ ${select('navbar_list', tx('st.navbar_list'), a.navbar_list ?? '', listChoices(a.navbar_list, tx('st.none_option')), tx('st.navbar_help'))}
498
+ ${select('mode', tx('st.mode'), a.theme?.mode ?? 'auto', (['auto', 'light', 'dark'] as const).map((k): [string, string] => [k, tx(`st.mode.${k}`)]))}
498
499
  </div>
499
- <div class="field"><label class="check"><input type="checkbox" name="user_choice" value="true"${a.theme?.user_choice !== false ? raw(' checked') : ''}> Users may choose light or dark</label>
500
- <small class="help">Adds a switch to the user menu and My account; the choice is saved on the account (APEX: "Enable End Users to Choose Theme Style").</small></div>
501
- <p><a class="btn" href="${BASE}/apps/${a.id}/theme">${icon('settings')} Theme Roller: style variants…</a>
502
- <span class="muted">${(() => { const n = appStyles(a.theme).length; return n ? `${n} style${n === 1 ? '' : 's'}` : 'no styles yet'; })()}</span></p>
500
+ <div class="field"><label class="check"><input type="checkbox" name="user_choice" value="true"${a.theme?.user_choice !== false ? raw(' checked') : ''}> ${tx('st.user_choice')}</label>
501
+ <small class="help">${tx('st.user_choice_help')}</small></div>
502
+ <p><a class="btn" href="${BASE}/apps/${a.id}/theme">${icon('settings')} ${tx('st.roller')}</a>
503
+ <span class="muted">${(() => { const n = appStyles(a.theme).length; return n ? tx(n === 1 ? 'st.styles_one' : 'st.styles', { n }) : tx('st.no_styles'); })()}</span></p>
503
504
  `)}${end}
504
- ${panel('globalization')}${region('Globalization', html`
505
+ ${panel('globalization')}${region(tx('st.tab.globalization'), html`
505
506
  <div class="form-grid">
506
- ${input('language', 'Primary language', a.language, { help: 'The language the app is built in, e.g. en, nl, de, en-GB.' })}
507
- ${input('languages', 'Translated languages', (a.languages ?? []).join(', '), { placeholder: 'e.g. nl, de', help: 'Comma separated. Translate texts under Shared Components → Globalization.' })}
508
- ${select('language_from', 'Language derived from', a.language_from, [['browser', 'Browser (Accept-Language)'], ['user', 'User preference, then browser'], ['primary', 'Always the primary language']], 'Users can also switch with ?lang=xx or the picker on the login page.')}
509
- ${input('date_format', 'Date format', a.date_format, { placeholder: 'e.g. DD-MM-YYYY (empty: per language)', help: 'Masks: YYYY YY MM MON MONTH DD DY DAY HH24 HH MI SS AM' })}
510
- ${input('timestamp_format', 'Date and time format', a.timestamp_format, { placeholder: 'e.g. DD-MM-YYYY HH24:MI' })}
507
+ ${input('language', tx('st.language'), a.language, { help: tx('st.language_help') })}
508
+ ${input('languages', tx('st.languages'), (a.languages ?? []).join(', '), { placeholder: tx('st.languages_ph'), help: tx('st.languages_help') })}
509
+ ${select('language_from', tx('st.language_from'), a.language_from, (['browser', 'user', 'primary'] as const).map((k): [string, string] => [k, tx(`st.from.${k}`)]), tx('st.language_from_help'))}
510
+ ${input('date_format', tx('st.date_format'), a.date_format, { placeholder: tx('st.date_format_ph'), help: tx('st.date_format_help') })}
511
+ ${input('timestamp_format', tx('st.ts_format'), a.timestamp_format, { placeholder: tx('st.ts_format_ph') })}
511
512
  </div>
512
513
  ${await timeZoneSettings(a)}`)}${end}
513
- <div class="buttons settings-save"${['errors', 'pwa'].includes(tab) ? raw(' hidden') : ''}><button class="btn btn-hot">Save settings</button></div>
514
+ <div class="buttons settings-save"${['errors', 'pwa'].includes(tab) ? raw(' hidden') : ''}><button class="btn btn-hot">${tx('st.save_settings')}</button></div>
514
515
  </form>
515
516
  ${panel('definition')}<form method="post" action="${BASE}/apps/${a.id}/delete" class="danger-zone">${csrf(s)}
516
- <button class="btn btn-danger" data-confirm="Delete application ${a.name} and all its pages?">Delete application</button>
517
+ <button class="btn btn-danger" data-confirm="${tx('st.delete_confirm', { app: a.name })}">${tx('st.delete')}</button>
517
518
  </form>${end}
518
- ${panel('errors')}${region('Errors and sessions', html`<form method="post" action="${BASE}/apps/${a.id}/settings/behaviour">${csrf(s)}
519
+ ${panel('errors')}${region(tx('st.tab.errors'), html`<form method="post" action="${BASE}/apps/${a.id}/settings/behaviour">${csrf(s)}
519
520
  <div class="form-grid">
520
- ${input('error_function', 'Error handling function', a.error_function ?? '', { placeholder: 'e.g. sales.error_message',
521
- help: 'schema.function(jsonb) returns jsonb, run as the application\'s role: it receives the message and what is known of an error (code, constraint, table, column, component, page, user, internal, reference) and may return {"message": "…"} to show instead. Empty: none.' })}
522
- ${input('session_warning_seconds', 'Session timeout warning (seconds)', a.session_warning_seconds ?? 120, { type: 'number',
523
- help: 'Signed-in users are warned this long before their session ends and can stay signed in (0: no warning).' })}
521
+ ${input('error_function', tx('st.error_fn'), a.error_function ?? '', { placeholder: tx('st.error_fn_ph'),
522
+ help: tx('st.error_fn_help') })}
523
+ ${input('session_warning_seconds', tx('st.warning'), a.session_warning_seconds ?? 120, { type: 'number',
524
+ help: tx('st.warning_help') })}
524
525
  </div>
525
- <div class="buttons"><button class="btn">Save</button></div></form>`)}${end}
526
- ${panel('pwa')}${region('Progressive Web App', await pwaSection({ ...a, has_icon: !!(await owner.one('select pwa_icon is not null as h from meta.app where id = $1', [a.id]))?.h }, s))}${end}
527
- ${panel('security')}${region('Security checklist', html`<ul class="checklist">
528
- <li>${a.db_role ? '✓' : '✗'} Runs as a dedicated database role ${a.db_role ? html`(<code>${a.db_role}</code>)` : html`<b>(runs as the runtime connection)</b>`}</li>
529
- <li>${a.authentication !== 'none' ? '✓' : '•'} ${a.authentication !== 'none' ? 'Users must sign in' : 'Public application'}</li>
526
+ <div class="buttons"><button class="btn">${tx('st.save')}</button></div></form>`)}${end}
527
+ ${panel('pwa')}${region(tx('st.tab.pwa'), await pwaSection({ ...a, has_icon: !!(await owner.one('select pwa_icon is not null as h from meta.app where id = $1', [a.id]))?.h }, s))}${end}
528
+ ${panel('security')}${region(tx('st.checklist'), html`<ul class="checklist">
529
+ <li>${a.db_role ? '✓' : '✗'} ${tx('st.ck.role')} ${a.db_role ? html`(<code>${a.db_role}</code>)` : html`<b>${tx('st.ck.runtime')}</b>`}</li>
530
+ <li>${a.authentication !== 'none' ? '✓' : '•'} ${a.authentication !== 'none' ? tx('st.ck.signin') : tx('st.ck.public')}</li>
530
531
  ${a.authentication === 'header' ? html`<li>${headerProxiesConfigured() ? '✓' : '✗'} Sign-in: HTTP header <code>${a.header_name || DEFAULT_HEADER}</code> ${headerProxiesConfigured() ? 'from the proxies in PGKILN_AUTH_HEADER_PROXIES' : html`<b>refused: PGKILN_AUTH_HEADER_PROXIES is not set</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}${a.header_auto_create ? ', new accounts created automatically' : ''}</li>` : ''}
531
532
  ${a.authentication === 'database' ? html`<li>${a.db_auth_roles?.length || a.db_auth_member_of ? '✓' : '✗'} Sign-in: database accounts (${[a.db_auth_roles?.length ? `roles ${a.db_auth_roles.join(', ')}` : '', a.db_auth_member_of ? `members of ${a.db_auth_member_of}` : ''].filter(Boolean).join('; ') || html`<b>no roles allowed</b>`})</li>` : ''}
532
533
  ${a.authentication === 'custom' ? html`<li>${a.custom_auth_function || a.custom_auth_code ? '✓' : '✗'} Sign-in: custom ${a.custom_auth_function ? html`function <code>${a.custom_auth_function}</code>` : a.custom_auth_code ? 'function body' : html`<b>no check configured: nobody can sign in</b>`}${a.custom_auth_post_code ? ', with post-authentication code' : ''}</li>` : ''}
533
534
  ${a.authentication === 'app_users' ? html`<li>Sign-in: ${[a.local_login ? 'password' : '', ...a.ldap_directories.map((d: string) => `LDAP ${d}`), ...a.sso_providers].filter(Boolean).join(', ') || html`<b>no method enabled</b>`}; access: ${a.access_control === 'any_user' ? 'any active account' : 'listed accounts only'}</li>` : ''}
534
- <li>${a.debug ? '✗ Debug mode is on: error details are shown to users' : '✓ Debug mode is off'}</li>
535
- <li>${a.debug_level ? `✗ Debug messages are on (level ${a.debug_level}): every request is recorded` : '✓ Debug messages are off'}</li>
536
- <li>Pages without checksum protection: ${(await owner.one("select count(*)::int as n from meta.page where app_id = $1 and protection = 'unrestricted'", [a.id])).n}</li>
537
- <li>Public pages: ${(await owner.one('select count(*)::int as n from meta.page where app_id = $1 and not requires_auth', [a.id])).n}</li>
535
+ <li>${a.debug ? `✗ ${tx('st.ck.debug_on')}` : `✓ ${tx('st.ck.debug_off')}`}</li>
536
+ <li>${a.debug_level ? `✗ ${tx('st.ck.msgs_on', { level: a.debug_level })}` : `✓ ${tx('st.ck.msgs_off')}`}</li>
537
+ <li>${tx('st.ck.unprotected', { n: (await owner.one("select count(*)::int as n from meta.page where app_id = $1 and protection = 'unrestricted'", [a.id])).n })}</li>
538
+ <li>${tx('st.ck.public_pages', { n: (await owner.one('select count(*)::int as n from meta.page where app_id = $1 and not requires_auth', [a.id])).n })}</li>
538
539
  </ul>`)}${end}
539
540
  </div>`;
540
- return send(reply, s, shell(s, `${a.name} settings`, [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Settings']], main));
541
+ return send(reply, s, shell(s, tx('st.title', { app: a.name }), [['App Builder', BASE], [a.name, `${BASE}/apps/${a.id}`], ['Settings']], main));
541
542
  });
542
543
 
543
544
  // (085) error handling function and session timeout warning
@@ -548,13 +549,13 @@ export async function builderRoutes(app: FastifyInstance) {
548
549
  const fn = String(b.error_function ?? '').trim();
549
550
  const seconds = Number(b.session_warning_seconds ?? 120);
550
551
  try {
551
- if (fn && !/^[A-Za-z_][A-Za-z0-9_$]{0,62}\.[A-Za-z_][A-Za-z0-9_$]{0,62}$/.test(fn)) throw new Error('The error handling function is schema.function, e.g. sales.error_message.');
552
+ if (fn && !/^[A-Za-z_][A-Za-z0-9_$]{0,62}\.[A-Za-z_][A-Za-z0-9_$]{0,62}$/.test(fn)) throw new Error(tr(s)('st.err.fn_shape'));
552
553
  if (fn && !(await owner.one(`select 1 as ok from pg_proc p join pg_namespace n on n.oid = p.pronamespace
553
554
  where n.nspname = $1 and p.proname = $2 and p.pronargs = 1 and p.proargtypes[0] = 'jsonb'::regtype and p.prorettype = 'jsonb'::regtype`, fn.split('.'))))
554
- throw new Error(`There is no function ${fn}(jsonb) returning jsonb.`);
555
- if (!Number.isInteger(seconds) || seconds < 0 || seconds > 3600) throw new Error('The session timeout warning is 0 to 3600 seconds.');
555
+ throw new Error(tr(s)('st.err.no_fn', { fn }));
556
+ if (!Number.isInteger(seconds) || seconds < 0 || seconds > 3600) throw new Error(tr(s)('st.err.warning'));
556
557
  await owner.query('update meta.app set error_function = $2, session_warning_seconds = $3, updated_at = now() where id = $1', [req.params.id, fn || null, seconds]);
557
- flash(s, 'Saved.');
558
+ flash(s, tr(s)('st.saved'));
558
559
  } catch (e) {
559
560
  flash(s, (e as Error).message, 'error');
560
561
  }
@@ -612,7 +613,7 @@ export async function builderRoutes(app: FastifyInstance) {
612
613
  );
613
614
  await saveTimeZoneSettings(req.params.id, b);
614
615
  await saveLoginSettings(req.params.id, b);
615
- flash(s, 'Settings saved.');
616
+ flash(s, tr(s)('st.settings_saved'));
616
617
  } catch (e) {
617
618
  flash(s, (e as Error).message, 'error');
618
619
  }
@@ -625,7 +626,7 @@ export async function builderRoutes(app: FastifyInstance) {
625
626
  const s = await developer(req, reply);
626
627
  if (!s) return;
627
628
  await owner.query('delete from meta.app where id = $1', [req.params.id]);
628
- flash(s, 'Application deleted.');
629
+ flash(s, tr(s)('st.app_deleted'));
629
630
  return back(reply, s, BASE);
630
631
  });
631
632