pgkiln 0.80.0 → 0.81.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.
@@ -8,6 +8,7 @@ import {
8
8
  type DefaultPriv, type ExistingPriv, type RoleContext, type RoleSpec, type SchemaGrant,
9
9
  } from '../dbroles.ts';
10
10
  import { administrator, back, BASE, csrf, flash, region, send, shell, type Body, type Req } from './ui.ts';
11
+ import { tr, type BuilderTranslate } from './i18n.ts';
11
12
 
12
13
  // (sprint 99, Database Actions DA-13) Administration → Database Users: the PostgreSQL roles of this cluster with
13
14
  // their sign-in, attributes, expiry, memberships and what they own; create, change and drop roles. Administrators
@@ -158,50 +159,51 @@ const sel = (on: boolean) => (on ? raw(' selected') : '');
158
159
  const errorList = (errors: string[]) =>
159
160
  errors.length ? html`<div class="alert alert-error" role="alert"><ul class="u-m0">${errors.map((e) => html`<li>${e}</li>`)}</ul></div>` : '';
160
161
 
161
- const DEFAULT_LABELS: [DefaultPriv, string][] = [['none', 'None'], ['read', 'Read (SELECT)'], ['write', 'Read and write']];
162
- const EXISTING_LABELS: [ExistingPriv, string][] = [['', 'No change'], ['read', 'Grant read'], ['write', 'Grant read and write'], ['revoke', 'Revoke all']];
162
+ const DEFAULT_LABELS = (tx: BuilderTranslate): [DefaultPriv, string][] => [['none', tx('dbu.priv.none')], ['read', tx('dbu.priv.read')], ['write', tx('dbu.priv.write')]];
163
+ const EXISTING_LABELS = (tx: BuilderTranslate): [ExistingPriv, string][] => [['', tx('dbu.existing.none')], ['read', tx('dbu.existing.read')], ['write', tx('dbu.existing.write')], ['revoke', tx('dbu.existing.revoke')]];
163
164
 
164
165
  /** The create / edit form. */
165
166
  function roleForm(s: Session, spec: RoleSpec, o: { isNew: boolean; before: RoleSpec | null; grantable: string[]; errors: string[] }) {
167
+ const tx = tr(s);
166
168
  const options = [...new Set([...o.grantable, ...(o.before?.memberOf ?? []), ...spec.memberOf])];
167
- const pwModes: [string, string][] = o.isNew ? [['keep', 'No password'], ['set', 'Set a password']] : [['keep', 'Keep the password'], ['set', 'Set a new password'], ['remove', 'Remove the password']];
169
+ const pwModes: [string, string][] = o.isNew ? [['keep', tx('dbu.pw.none')], ['set', tx('dbu.pw.set')]] : [['keep', tx('dbu.pw.keep')], ['set', tx('dbu.pw.new')], ['remove', tx('dbu.pw.remove')]];
168
170
  return html`${errorList(o.errors)}
169
171
  <form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}
170
172
  <input type="hidden" name="original" value="${o.isNew ? '' : spec.name}">
171
- ${region('Role', html`<div class="form-grid">
172
- ${o.isNew ? html`<div class="field"><label class="label" for="f_name">Name</label>
173
+ ${region(tx('dbu.role'), html`<div class="form-grid">
174
+ ${o.isNew ? html`<div class="field"><label class="label" for="f_name">${tx('dbu.name')}</label>
173
175
  <input id="f_name" name="name" value="${spec.name}" required maxlength="63" pattern="[A-Za-z_][A-Za-z0-9_$]*" autocomplete="off" spellcheck="false">
174
- <small class="help">Letters, digits, _ and $; names starting with pg_ or pgkiln are reserved.</small></div>` : ''}
175
- <div class="field"><span class="label">Sign in</span><label class="check"><input type="checkbox" name="login" value="1"${chk(spec.login)}> Can sign in (LOGIN)</label>
176
- <small class="help">A role without it is a group role: others get its privileges by being members.</small></div>
177
- <fieldset class="field"><legend class="label">Password</legend><div class="radio-group">
176
+ <small class="help">${tx('dbu.name_help')}</small></div>` : ''}
177
+ <div class="field"><span class="label">${tx('dbu.sign_in')}</span><label class="check"><input type="checkbox" name="login" value="1"${chk(spec.login)}> ${tx('dbu.can_login')}</label>
178
+ <small class="help">${tx('dbu.can_login_help')}</small></div>
179
+ <fieldset class="field"><legend class="label">${tx('dbu.password')}</legend><div class="radio-group">
178
180
  ${pwModes.map(([v, l]) => html`<label class="check"><input type="radio" name="password_mode" value="${v}"${chk(spec.password === v)}> ${l}</label>`)}
179
- </div><small class="help">You type the password on the next step, after reviewing the SQL. It is stored as a SCRAM-SHA-256 verifier and never shown.</small></fieldset>
180
- <div class="field"><label class="label" for="f_valid_until">Valid until</label>
181
+ </div><small class="help">${tx('dbu.password_help')}</small></fieldset>
182
+ <div class="field"><label class="label" for="f_valid_until">${tx('dbu.valid_until')}</label>
181
183
  <input id="f_valid_until" name="valid_until" type="date" value="${spec.validUntil}">
182
- <small class="help">The password stops working after this date (VALID UNTIL). Empty: it doesn't expire.</small></div>
183
- <div class="field"><label class="label" for="f_conn_limit">Connection limit</label>
184
+ <small class="help">${tx('dbu.valid_until_help')}</small></div>
185
+ <div class="field"><label class="label" for="f_conn_limit">${tx('dbu.conn_limit')}</label>
184
186
  <input id="f_conn_limit" name="conn_limit" type="number" min="-1" max="100000" step="1" value="${Number.isFinite(spec.connLimit) && spec.connLimit >= 0 ? String(spec.connLimit) : ''}">
185
- <small class="help">Empty: no limit.</small></div>
186
- <div class="field"><label class="label" for="f_search_path">Default schema (search path)</label>
187
+ <small class="help">${tx('dbu.conn_limit_help')}</small></div>
188
+ <div class="field"><label class="label" for="f_search_path">${tx('dbu.search_path')}</label>
187
189
  <input id="f_search_path" name="search_path" value="${spec.searchPath}" placeholder="sales, public" spellcheck="false" autocomplete="off">
188
- <small class="help">Schemas, comma separated, for this role's sessions in this database. Empty: the server's default.</small></div>
189
- <div class="field"><label class="label" for="f_comment">Comment</label>
190
+ <small class="help">${tx('dbu.search_path_help')}</small></div>
191
+ <div class="field"><label class="label" for="f_comment">${tx('dbu.comment')}</label>
190
192
  <input id="f_comment" name="comment" value="${spec.comment}" maxlength="1000"></div>
191
193
  </div>`)}
192
- ${region('Member of', html`<p class="muted u-mt0">The role gets the privileges of the roles it is a member of. pgkiln's own roles, superusers, login roles and the predefined roles that reach the server's files can't be granted here.</p>
193
- ${options.length ? html`<fieldset class="field"><legend class="sr-only">Member of</legend><div class="radio-group dbu-members">
194
+ ${region(tx('dbu.member_of'), html`<p class="muted u-mt0">${tx('dbu.member_of_intro')}</p>
195
+ ${options.length ? html`<fieldset class="field"><legend class="sr-only">${tx('dbu.member_of')}</legend><div class="radio-group dbu-members">
194
196
  ${options.map((m) => html`<label class="check"><input type="checkbox" name="member" value="${m}"${chk(spec.memberOf.includes(m))}> ${m}</label>`)}
195
- </div></fieldset>` : html`<p class="muted">There are no group roles to grant.</p>`}`)}
196
- ${region('Schemas', html`<p class="muted u-mt0"><strong>Usage</strong> lets the role use a schema's objects (as far as their own privileges allow), <strong>Create</strong> make objects in it. <strong>Future tables</strong> are default privileges on the tables (and, for writing, sequences) pgkiln's owner connection creates there from now on; <strong>Existing tables</strong> grants or revokes once on the tables there now. pgkiln's own schemas aren't listed.</p>
197
- ${spec.schemas.length ? html`<div class="table-wrap"><table class="report dbu-schemas"><thead><tr><th>Schema</th><th class="te-c">Usage</th><th class="te-c">Create</th><th>Future tables</th><th>Existing tables</th></tr></thead>
197
+ </div></fieldset>` : html`<p class="muted">${tx('dbu.no_groups')}</p>`}`)}
198
+ ${region(tx('dbu.schemas'), html`<p class="muted u-mt0">${tx.rich('dbu.schemas_intro', { usage: html`<strong>${tx('dbu.col.usage')}</strong>`, create: html`<strong>${tx('dbu.col.create')}</strong>`, future: html`<strong>${tx('dbu.col.future')}</strong>`, existing: html`<strong>${tx('dbu.col.existing')}</strong>` })}</p>
199
+ ${spec.schemas.length ? html`<div class="table-wrap"><table class="report dbu-schemas"><thead><tr><th>${tx('dbu.col.schema')}</th><th class="te-c">${tx('dbu.col.usage')}</th><th class="te-c">${tx('dbu.col.create')}</th><th>${tx('dbu.col.future')}</th><th>${tx('dbu.col.existing')}</th></tr></thead>
198
200
  <tbody>${spec.schemas.map((x, i) => html`<tr><td><code>${x.schema}</code><input type="hidden" name="s.${i}.name" value="${x.schema}"></td>
199
- <td class="te-c"><input type="checkbox" name="s.${i}.usage" value="1" aria-label="Usage on ${x.schema}"${chk(x.usage)}></td>
200
- <td class="te-c"><input type="checkbox" name="s.${i}.create" value="1" aria-label="Create in ${x.schema}"${chk(x.create)}></td>
201
- <td><select name="s.${i}.defaults" aria-label="Future tables in ${x.schema}">${x.defaults === 'custom' ? html`<option value="custom" selected>As they are (custom)</option>` : ''}${DEFAULT_LABELS.map(([v, l]) => html`<option value="${v}"${sel(x.defaults === v)}>${l}</option>`)}</select></td>
202
- <td><select name="s.${i}.existing" aria-label="Existing tables in ${x.schema}">${EXISTING_LABELS.map(([v, l]) => html`<option value="${v}"${sel((x.existing ?? '') === v)}>${l}</option>`)}</select></td></tr>`)}
203
- </tbody></table></div>` : html`<p class="muted">No schemas.</p>`}`)}
204
- <div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')} Review SQL</button><a class="btn" href="${URL_BASE}">Cancel</a></div>
201
+ <td class="te-c"><input type="checkbox" name="s.${i}.usage" value="1" aria-label="${tx('dbu.usage_on', { schema: x.schema })}"${chk(x.usage)}></td>
202
+ <td class="te-c"><input type="checkbox" name="s.${i}.create" value="1" aria-label="${tx('dbu.create_in', { schema: x.schema })}"${chk(x.create)}></td>
203
+ <td><select name="s.${i}.defaults" aria-label="${tx('dbu.future_in', { schema: x.schema })}">${x.defaults === 'custom' ? html`<option value="custom" selected>${tx('dbu.custom')}</option>` : ''}${DEFAULT_LABELS(tx).map(([v, l]) => html`<option value="${v}"${sel(x.defaults === v)}>${l}</option>`)}</select></td>
204
+ <td><select name="s.${i}.existing" aria-label="${tx('dbu.existing_in', { schema: x.schema })}">${EXISTING_LABELS(tx).map(([v, l]) => html`<option value="${v}"${sel((x.existing ?? '') === v)}>${l}</option>`)}</select></td></tr>`)}
205
+ </tbody></table></div>` : html`<p class="muted">${tx('dbu.no_schemas')}</p>`}`)}
206
+ <div class="buttons"><button class="btn btn-hot" name="mode" value="review">${icon('eye')} ${tx('dbu.review')}</button><a class="btn" href="${URL_BASE}">${tx('dbu.cancel')}</a></div>
205
207
  </form>`;
206
208
  }
207
209
 
@@ -220,64 +222,66 @@ const hiddenSpec = (spec: RoleSpec, isNew: boolean) => html`
220
222
 
221
223
  /** The review step: the SQL (the password masked), the password fields when one is set, Apply. */
222
224
  function reviewForm(s: Session, spec: RoleSpec, stmts: string[], o: { isNew: boolean; errors: string[]; output?: Raw | '' }) {
225
+ const tx = tr(s);
223
226
  return html`${errorList(o.errors)}${o.output ?? ''}
224
- ${region('SQL', stmts.length
225
- ? html`<p class="muted u-mt0">These statements run in one transaction when you apply them: all of them, or none.${spec.password === 'set' ? ' The password shows as \'********\': pgkiln sends PostgreSQL only its SCRAM-SHA-256 verifier.' : ''}</p>
226
- <pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${maskSql(stmts)}</pre>`
227
- : html`<p class="muted u-mt0">Nothing to change: the role is as the form says.</p>`)}
227
+ ${region(tx('dbu.sql'), stmts.length
228
+ ? html`<p class="muted u-mt0">${tx('dbu.sql_intro')}${spec.password === 'set' ? ` ${tx('dbu.sql_masked')}` : ''}</p>
229
+ <pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="${tx('dbu.generated_sql')}">${maskSql(stmts)}</pre>`
230
+ : html`<p class="muted u-mt0">${tx('dbu.nothing_sql')}</p>`)}
228
231
  <form method="post" action="${URL_BASE}/save" class="dbu-form">${csrf(s)}${hiddenSpec(spec, o.isNew)}
229
232
  <input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
230
- ${spec.password === 'set' ? region('Password', html`<div class="form-grid">
231
- <div class="field"><label class="label" for="f_password">Password</label><input id="f_password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
232
- <div class="field"><label class="label" for="f_confirm">Confirm password</label><input id="f_confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
233
- </div><small class="help">At least 8 characters. It is hashed by pgkiln before it reaches the database, and it is not kept.</small>`) : ''}
234
- <div class="buttons">${stmts.length ? html`<button class="btn btn-hot" name="mode" value="apply">${icon('check')} Apply</button>` : ''}
235
- <button class="btn" name="mode" value="edit" formnovalidate>Back to the form</button></div>
233
+ ${spec.password === 'set' ? region(tx('dbu.password'), html`<div class="form-grid">
234
+ <div class="field"><label class="label" for="f_password">${tx('dbu.password')}</label><input id="f_password" name="password" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
235
+ <div class="field"><label class="label" for="f_confirm">${tx('dbu.confirm_password')}</label><input id="f_confirm" name="confirm" type="password" autocomplete="new-password" minlength="8" maxlength="1024" required></div>
236
+ </div><small class="help">${tx('dbu.password_note')}</small>`) : ''}
237
+ <div class="buttons">${stmts.length ? html`<button class="btn btn-hot" name="mode" value="apply">${icon('check')} ${tx('dbu.apply')}</button>` : ''}
238
+ <button class="btn" name="mode" value="edit" formnovalidate>${tx('dbu.back_form')}</button></div>
236
239
  </form>`;
237
240
  }
238
241
 
239
242
  /** A role's attributes as tags. */
240
- const attrTags = (r: RoleRow) => html`${r.pgkiln ? html`<span class="tag tag-info">pgkiln</span> ` : ''}${r.used_by.length ? html`<span class="tag" title="${r.used_by.join(', ')}">in use</span> ` : ''}${
241
- r.superuser ? html`<span class="tag tag-error">superuser</span> ` : ''}${r.bypassrls ? html`<span class="tag tag-warning">bypass RLS</span> ` : ''}${
242
- r.createrole ? html`<span class="tag tag-warning">create role</span> ` : ''}${r.createdb ? html`<span class="tag">create DB</span> ` : ''}${r.replication ? html`<span class="tag">replication</span>` : ''}`;
243
+ const attrTags = (tx: BuilderTranslate, r: RoleRow) => html`${r.pgkiln ? html`<span class="tag tag-info">pgkiln</span> ` : ''}${r.used_by.length ? html`<span class="tag" title="${r.used_by.join(', ')}">${tx('dbu.tag.in_use')}</span> ` : ''}${
244
+ r.superuser ? html`<span class="tag tag-error">${tx('dbu.tag.superuser')}</span> ` : ''}${r.bypassrls ? html`<span class="tag tag-warning">${tx('dbu.tag.bypassrls')}</span> ` : ''}${
245
+ r.createrole ? html`<span class="tag tag-warning">${tx('dbu.tag.createrole')}</span> ` : ''}${r.createdb ? html`<span class="tag">${tx('dbu.tag.createdb')}</span> ` : ''}${r.replication ? html`<span class="tag">${tx('dbu.tag.replication')}</span>` : ''}`;
243
246
 
244
- const expiry = (r: RoleRow) => r.valid_until === null ? html`<span class="muted">never</span>` : html`${r.valid_until}${
245
- r.days_left !== null && r.days_left < 0 ? html` <span class="tag tag-error">expired</span>` : r.days_left !== null && r.days_left <= 30 ? html` <span class="tag tag-warning">in ${String(r.days_left)} day${r.days_left === 1 ? '' : 's'}</span>` : ''}`;
247
+ const expiry = (tx: BuilderTranslate, r: RoleRow) => r.valid_until === null ? html`<span class="muted">${tx('dbu.never')}</span>` : html`${r.valid_until}${
248
+ r.days_left !== null && r.days_left < 0 ? html` <span class="tag tag-error">${tx('dbu.expired')}</span>` : r.days_left !== null && r.days_left <= 30 ? html` <span class="tag tag-warning">${tx(r.days_left === 1 ? 'dbu.in_days_one' : 'dbu.in_days', { n: r.days_left })}</span>` : ''}`;
246
249
 
247
- const FILTERS: [string, string, (r: RoleRow) => boolean][] = [
248
- ['', 'All roles', () => true],
249
- ['login', 'Can sign in', (r) => r.login],
250
- ['expiring', 'Password expires within 30 days', (r) => r.login && r.days_left !== null && r.days_left >= 0 && r.days_left <= 30],
251
- ['bypassrls', 'Bypass row level security', (r) => r.bypassrls],
252
- ['owners', 'No sign-in, but own objects', (r) => !r.login && r.owned > 0],
253
- ['pgkiln', 'pgkiln\'s own', (r) => r.pgkiln],
254
- ];
250
+ const FILTERS: [string, 'dbu.f.all', (r: RoleRow) => boolean][] = ([
251
+ ['', 'dbu.f.all', () => true],
252
+ ['login', 'dbu.f.login', (r) => r.login],
253
+ ['expiring', 'dbu.f.expiring', (r) => r.login && r.days_left !== null && r.days_left >= 0 && r.days_left <= 30],
254
+ ['bypassrls', 'dbu.f.bypassrls', (r) => r.bypassrls],
255
+ ['owners', 'dbu.f.owners', (r) => !r.login && r.owned > 0],
256
+ ['pgkiln', 'dbu.f.pgkiln', (r) => r.pgkiln],
257
+ ] as [string, string, (r: RoleRow) => boolean][]) as [string, 'dbu.f.all', (r: RoleRow) => boolean][];
255
258
 
256
259
  async function listPage(req: Req, reply: FastifyReply, s: Session) {
257
260
  const roles = await listRoles();
258
261
  const f = FILTERS.find(([k]) => k === (req.query.f ?? '')) ?? FILTERS[0];
259
262
  const q = String(req.query.q ?? '').trim().toLowerCase().slice(0, 100);
260
263
  const shown = roles.filter((r) => f[2](r) && (!q || r.name.toLowerCase().includes(q)));
261
- const main = html`<div class="ide-head-title u-mb1"><div><h1>${TITLE}</h1><span class="muted small">The PostgreSQL roles of this server (pg_roles), without the predefined pg_ roles.</span></div>
262
- <div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/new">${icon('plus')} Create database user</a></div></div>
263
- <nav class="stat-grid u-mb1 dbu-stats" aria-label="Filters">${FILTERS.map(([k, label, fn]) => html`<a class="stat" href="${URL_BASE}${k ? `?f=${k}` : ''}"${k === f[0] ? raw(' aria-current="page"') : ''}><b>${String(roles.filter(fn).length)}</b><span>${label}</span></a>`)}</nav>
264
- ${region(f[1], html`<form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search">${f[0] ? html`<input type="hidden" name="f" value="${f[0]}">` : ''}
265
- <label class="sr-only" for="dbu-q">Search roles</label><input id="dbu-q" name="q" type="search" value="${q}" placeholder="Search roles" autocomplete="off"> <button class="btn btn-sm">${icon('search')} Search</button></form>
266
- <div class="table-wrap"><table class="report report-reflow dbu-list"><thead><tr><th>Role</th><th>Sign in</th><th>Attributes</th><th class="num">Connections</th><th>Valid until</th><th>Member of</th><th>Owns</th></tr></thead>
264
+ const tx = tr(s);
265
+ const main = html`<div class="ide-head-title u-mb1"><div><h1>${tx('dbu.title')}</h1><span class="muted small">${tx('dbu.list_intro')}</span></div>
266
+ <div class="buttons ide-head-actions"><a class="btn btn-hot" href="${URL_BASE}/new">${icon('plus')} ${tx('dbu.create_user')}</a></div></div>
267
+ <nav class="stat-grid u-mb1 dbu-stats" aria-label="${tx('dbu.filters')}">${FILTERS.map(([k, label, fn]) => html`<a class="stat" href="${URL_BASE}${k ? `?f=${k}` : ''}"${k === f[0] ? raw(' aria-current="page"') : ''}><b>${String(roles.filter(fn).length)}</b><span>${tx(label)}</span></a>`)}</nav>
268
+ ${region(tx(f[1]), html`<form method="get" action="${URL_BASE}" class="dbu-search u-mb1" role="search">${f[0] ? html`<input type="hidden" name="f" value="${f[0]}">` : ''}
269
+ <label class="sr-only" for="dbu-q">${tx('dbu.search_roles')}</label><input id="dbu-q" name="q" type="search" value="${q}" placeholder="${tx('dbu.search_roles')}" autocomplete="off"> <button class="btn btn-sm">${icon('search')} ${tx('dbu.search')}</button></form>
270
+ <div class="table-wrap"><table class="report report-reflow dbu-list"><thead><tr><th>${tx('dbu.role')}</th><th>${tx('dbu.sign_in')}</th><th>${tx('dbu.col.attributes')}</th><th class="num">${tx('dbu.col.connections')}</th><th>${tx('dbu.valid_until')}</th><th>${tx('dbu.member_of')}</th><th>${tx('dbu.col.owns')}</th></tr></thead>
267
271
  <tbody>${shown.length ? shown.map((r) => html`<tr>
268
- <td data-label="Role" class="u-break"><a href="${roleUrl(r.name)}"><strong>${r.name}</strong></a>${r.comment ? html`<br><span class="muted small">${r.comment}</span>` : ''}</td>
269
- <td data-label="Sign in">${r.login ? 'Yes' : html`<span class="muted">No</span>`}</td>
270
- <td data-label="Attributes">${attrTags(r)}</td>
271
- <td data-label="Connections" class="num">${r.conn_limit < 0 ? html`<span class="muted">no limit</span>` : String(r.conn_limit)}</td>
272
- <td data-label="Valid until">${expiry(r)}</td>
273
- <td data-label="Member of" class="u-break">${r.member_of.join(', ')}</td>
274
- <td data-label="Owns" class="u-break">${r.schemas.length ? html`schemas ${r.schemas.join(', ')}; ` : ''}${r.owned ? `${r.owned} object${r.owned === 1 ? '' : 's'}` : r.schemas.length ? '' : html`<span class="muted">nothing</span>`}${r.owned_elsewhere ? html` <span class="muted">(+${String(r.owned_elsewhere)} elsewhere)</span>` : ''}</td></tr>`)
275
- : html`<tr><td colspan="7" class="empty">No roles.</td></tr>`}</tbody></table></div>
276
- <p class="muted small">PostgreSQL doesn't record when a role was created. <span class="tag tag-info">pgkiln</span> roles are pgkiln's own connections and roles: they are shown, not changed here. Roles <span class="tag">in use</span> by an application or an isolated workspace can be changed but not dropped.</p>`)}`;
277
- return send(reply, s, shell(s, TITLE, crumbs(), main, 'users'));
272
+ <td data-label="${tx('dbu.role')}" class="u-break"><a href="${roleUrl(r.name)}"><strong>${r.name}</strong></a>${r.comment ? html`<br><span class="muted small">${r.comment}</span>` : ''}</td>
273
+ <td data-label="${tx('dbu.sign_in')}">${r.login ? tx('dbu.yes') : html`<span class="muted">${tx('dbu.no')}</span>`}</td>
274
+ <td data-label="${tx('dbu.col.attributes')}">${attrTags(tx, r)}</td>
275
+ <td data-label="${tx('dbu.col.connections')}" class="num">${r.conn_limit < 0 ? html`<span class="muted">${tx('dbu.no_limit')}</span>` : String(r.conn_limit)}</td>
276
+ <td data-label="${tx('dbu.valid_until')}">${expiry(tx, r)}</td>
277
+ <td data-label="${tx('dbu.member_of')}" class="u-break">${r.member_of.join(', ')}</td>
278
+ <td data-label="${tx('dbu.col.owns')}" class="u-break">${r.schemas.length ? html`${tx('dbu.owns_schemas', { list: r.schemas.join(', ') })}; ` : ''}${r.owned ? tx(r.owned === 1 ? 'dbu.n_objects_one' : 'dbu.n_objects', { n: r.owned }) : r.schemas.length ? '' : html`<span class="muted">${tx('dbu.nothing')}</span>`}${r.owned_elsewhere ? html` <span class="muted">${tx('dbu.elsewhere', { n: r.owned_elsewhere })}</span>` : ''}</td></tr>`)
279
+ : html`<tr><td colspan="7" class="empty">${tx('dbu.no_roles')}</td></tr>`}</tbody></table></div>
280
+ <p class="muted small">${tx.rich('dbu.list_note', { pgkiln: html`<span class="tag tag-info">pgkiln</span>`, in_use: html`<span class="tag">${tx('dbu.tag.in_use')}</span>` })}</p>`)}`;
281
+ return send(reply, s, shell(s, tx('dbu.title'), crumbs(tx), main, 'users'));
278
282
  }
279
283
 
280
- const crumbs = (...more: [string, string?][]): [string, string?][] => [['Workspace utilities', `${BASE}/utilities`], [TITLE, more.length ? URL_BASE : undefined], ...more];
284
+ const crumbs = (tx: BuilderTranslate, ...more: [string, string?][]): [string, string?][] => [[tx('admin.utilities'), `${BASE}/utilities`], [tx('dbu.title'), more.length ? URL_BASE : undefined], ...more];
281
285
 
282
286
  /** A role's details: owned objects, members, users of it. */
283
287
  async function details(r: RoleRow) {
@@ -290,73 +294,73 @@ async function details(r: RoleRow) {
290
294
  return { owned, members };
291
295
  }
292
296
 
293
- function detailRegion(r: RoleRow, d: { owned: string[]; members: string[] }) {
294
- return region('Details', html`<dl class="dbu-facts">
295
- <dt>Attributes</dt><dd>${r.login ? 'Can sign in' : 'No sign-in (a group role)'} ${attrTags(r)}</dd>
296
- <dt>Valid until</dt><dd>${expiry(r)}</dd>
297
- <dt>Connection limit</dt><dd>${r.conn_limit < 0 ? 'no limit' : String(r.conn_limit)}</dd>
298
- <dt>Member of</dt><dd class="u-break">${r.member_of.join(', ') || html`<span class="muted">none</span>`}</dd>
299
- <dt>Members</dt><dd class="u-break">${d.members.join(', ') || html`<span class="muted">none</span>`}</dd>
300
- ${r.used_by.length ? html`<dt>Used by</dt><dd class="u-break">${r.used_by.join(', ')}</dd>` : ''}
301
- <dt>Owns</dt><dd class="u-break">${d.owned.length ? html`${r.owned > d.owned.length ? `${r.owned} objects, the first ${d.owned.length}: ` : ''}${d.owned.join(', ')}` : html`<span class="muted">nothing in this database</span>`}${r.owned_elsewhere ? html` <span class="tag tag-warning">${String(r.owned_elsewhere)} in other databases or shared</span>` : ''}</dd>
297
+ function detailRegion(tx: BuilderTranslate, r: RoleRow, d: { owned: string[]; members: string[] }) {
298
+ return region(tx('dbu.details'), html`<dl class="dbu-facts">
299
+ <dt>${tx('dbu.col.attributes')}</dt><dd>${r.login ? tx('dbu.f.login') : tx('dbu.no_login')} ${attrTags(tx, r)}</dd>
300
+ <dt>${tx('dbu.valid_until')}</dt><dd>${expiry(tx, r)}</dd>
301
+ <dt>${tx('dbu.conn_limit')}</dt><dd>${r.conn_limit < 0 ? tx('dbu.no_limit') : String(r.conn_limit)}</dd>
302
+ <dt>${tx('dbu.member_of')}</dt><dd class="u-break">${r.member_of.join(', ') || html`<span class="muted">${tx('dbu.none')}</span>`}</dd>
303
+ <dt>${tx('dbu.members')}</dt><dd class="u-break">${d.members.join(', ') || html`<span class="muted">${tx('dbu.none')}</span>`}</dd>
304
+ ${r.used_by.length ? html`<dt>${tx('dbu.used_by')}</dt><dd class="u-break">${r.used_by.join(', ')}</dd>` : ''}
305
+ <dt>${tx('dbu.col.owns')}</dt><dd class="u-break">${d.owned.length ? html`${r.owned > d.owned.length ? `${tx('dbu.owned_first', { n: r.owned, first: d.owned.length })} ` : ''}${d.owned.join(', ')}` : html`<span class="muted">${tx('dbu.nothing_here')}</span>`}${r.owned_elsewhere ? html` <span class="tag tag-warning">${tx('dbu.owned_other', { n: r.owned_elsewhere })}</span>` : ''}</dd>
302
306
  </dl>`);
303
307
  }
304
308
 
305
309
  /** Can this role be changed here? A message when not. */
306
- const protectedMessage = (r: RoleRow) => r.pgkiln
307
- ? `${r.name} is one of pgkiln's own roles (its owner or runtime connection, a pgkiln_ role or the bootstrap superuser): pgkiln needs it as it is, so it isn't changed or dropped here.`
308
- : '';
310
+ const protectedMessage = (tx: BuilderTranslate, r: RoleRow) => r.pgkiln ? tx('dbu.protected', { name: r.name }) : '';
309
311
 
310
312
  async function rolePage(reply: FastifyReply, s: Session, name: string | null, o: { spec?: RoleSpec; errors?: string[] } = {}) {
313
+ const tx = tr(s);
311
314
  const [ctx, schemas, grantable] = await Promise.all([context(), grantSchemas(), grantableRoles()]);
312
315
  if (name === null) {
313
316
  const spec = o.spec ?? { ...emptyRole(), login: true, schemas: schemas.map((schema) => ({ schema, usage: false, create: false, defaults: 'none' as DefaultPriv })) };
314
- const main = html`<h1 class="u-mb1">Create database user</h1>${roleForm(s, spec, { isNew: true, before: null, grantable, errors: o.errors ?? [] })}`;
315
- return send(reply, s, shell(s, 'Create database user', crumbs(['Create']), main, 'users'));
317
+ const main = html`<h1 class="u-mb1">${tx('dbu.create_user')}</h1>${roleForm(s, spec, { isNew: true, before: null, grantable, errors: o.errors ?? [] })}`;
318
+ return send(reply, s, shell(s, tx('dbu.create_user'), crumbs(tx, [tx('dbu.crumb.create')]), main, 'users'));
316
319
  }
317
320
  const r = await oneRole(name);
318
321
  if (!r) return reply.code(404).send('Not found');
319
322
  const d = await details(r);
320
- const msg = protectedMessage(r);
323
+ const msg = protectedMessage(tx, r);
321
324
  const before = msg ? null : await readSpec(r, schemas, ctx.creator);
322
- const main = html`<div class="ide-head-title u-mb1"><div><h1 class="u-break">Role ${r.name}</h1>${r.comment ? html`<span class="muted small">${r.comment}</span>` : ''}</div>
323
- ${msg || r.used_by.length ? '' : html`<div class="buttons ide-head-actions"><a class="btn btn-danger" href="${roleUrl(r.name)}/drop">${icon('trash')} Drop role…</a></div>`}</div>
325
+ const main = html`<div class="ide-head-title u-mb1"><div><h1 class="u-break">${tx('dbu.role_title', { name: r.name })}</h1>${r.comment ? html`<span class="muted small">${r.comment}</span>` : ''}</div>
326
+ ${msg || r.used_by.length ? '' : html`<div class="buttons ide-head-actions"><a class="btn btn-danger" href="${roleUrl(r.name)}/drop">${icon('trash')} ${tx('dbu.drop_role_link')}</a></div>`}</div>
324
327
  ${msg ? html`<div class="alert alert-info" role="status">${msg}</div>` : ''}
325
- ${!msg && r.used_by.length ? html`<div class="alert alert-info" role="status">${r.used_by.join(', ')} use${r.used_by.length === 1 ? 's' : ''} this role: it can be changed, not dropped.</div>` : ''}
326
- ${detailRegion(r, d)}
328
+ ${!msg && r.used_by.length ? html`<div class="alert alert-info" role="status">${tx(r.used_by.length === 1 ? 'dbu.used_change_only_one' : 'dbu.used_change_only', { users: r.used_by.join(', ') })}</div>` : ''}
329
+ ${detailRegion(tx, r, d)}
327
330
  ${before ? roleForm(s, o.spec ?? before, { isNew: false, before, grantable, errors: o.errors ?? [] }) : ''}`;
328
- return send(reply, s, shell(s, `Role ${r.name}`, crumbs([r.name]), main, 'users'));
331
+ return send(reply, s, shell(s, tx('dbu.role_title', { name: r.name }), crumbs(tx, [r.name]), main, 'users'));
329
332
  }
330
333
 
331
334
  async function save(req: Req, reply: FastifyReply, s: Session) {
332
335
  const b = req.body ?? {};
336
+ const tx = tr(s);
333
337
  const original = one(b.original);
334
338
  const isNew = !original;
335
339
  const [ctx, schemas, grantable] = await Promise.all([context(), grantSchemas(), grantableRoles()]);
336
340
  const r = isNew ? null : await oneRole(original);
337
341
  if (!isNew && !r) return reply.code(404).send('Not found');
338
342
  if (r && r.pgkiln) {
339
- const main = html`<h1 class="u-mb1">Role ${r.name}</h1><div class="alert alert-error" role="alert">${protectedMessage(r)}</div>`;
340
- return send(reply.code(403), s, shell(s, `Role ${r.name}`, crumbs([r.name]), main, 'users'));
343
+ const main = html`<h1 class="u-mb1">${tx('dbu.role_title', { name: r.name })}</h1><div class="alert alert-error" role="alert">${protectedMessage(tx, r)}</div>`;
344
+ return send(reply.code(403), s, shell(s, tx('dbu.role_title', { name: r.name }), crumbs(tx, [r.name]), main, 'users'));
341
345
  }
342
346
  const before = r ? await readSpec(r, schemas, ctx.creator) : null;
343
347
  const spec = specFromBody(b, isNew, original);
344
348
  const mode = one(b.mode);
345
349
  if (mode === 'edit') return rolePage(reply, s, isNew ? null : original, { spec });
346
350
  const errors = checkRole(spec, { isNew, grantable, schemas, before });
347
- if (isNew && !errors.length && (await owner.one('select 1 from pg_roles where lower(rolname) = lower($1)', [spec.name]))) errors.push(`A role named ${spec.name} exists already.`);
351
+ if (isNew && !errors.length && (await owner.one('select 1 from pg_roles where lower(rolname) = lower($1)', [spec.name]))) errors.push(tx('dbu.exists', { name: spec.name }));
348
352
  if (errors.length) return rolePage(reply, s, isNew ? null : original, { spec, errors });
349
353
  const stmts = roleSql(before, spec, ctx);
350
- const title = isNew ? `Create database user ${spec.name}` : `Change role ${spec.name}`;
354
+ const title = isNew ? tx('dbu.create_title', { name: spec.name }) : tx('dbu.change_title', { name: spec.name });
351
355
  const page = (errs: string[], output: Raw | '' = '') => {
352
356
  const main = html`<h1 class="u-mb1 u-break">${title}</h1>${reviewForm(s, spec, stmts, { isNew, errors: errs, output })}`;
353
- const trail: [string, string?][] = isNew ? [['Create', `${URL_BASE}/new`], ['Review']] : [[spec.name, roleUrl(spec.name)], ['Review']];
354
- return send(reply.code(errs.length || output ? 422 : 200), s, shell(s, title, crumbs(...trail), main, 'users'));
357
+ const trail: [string, string?][] = isNew ? [[tx('dbu.crumb.create'), `${URL_BASE}/new`], [tx('dbu.crumb.review')]] : [[spec.name, roleUrl(spec.name)], [tx('dbu.crumb.review')]];
358
+ return send(reply.code(errs.length || output ? 422 : 200), s, shell(s, title, crumbs(tx, ...trail), main, 'users'));
355
359
  };
356
360
  if (mode !== 'apply') return page([]);
357
361
  // Apply runs exactly what the review showed: a change in the form or the catalog since then shows the review again
358
- if (one(b.reviewed) !== reviewDigest(stmts)) return page(['The statements changed since you reviewed them (the role or the form changed): review them again.']);
359
- if (!stmts.length) return page(['Nothing to change.']);
362
+ if (one(b.reviewed) !== reviewDigest(stmts)) return page([tx('dbu.changed_since')]);
363
+ if (!stmts.length) return page([tx('dbu.nothing_change')]);
360
364
  const password = one(b.password);
361
365
  if (spec.password === 'set') {
362
366
  const bad = checkPassword(password, one(b.confirm));
@@ -379,9 +383,9 @@ async function save(req: Req, reply: FastifyReply, s: Session) {
379
383
  }
380
384
  await logActivity({ username: s.username, event: 'db_role', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${maskSql(stmts)}`.slice(0, 4000) });
381
385
  if (failed) {
382
- return page([], html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p>${at >= 0 ? html`<pre class="source">${maskSql([stmts[at]])}</pre>` : ''}</div>`);
386
+ return page([], html`<div class="alert alert-error" role="alert"><p>${tx.rich('dbu.rolled_back', { nothing: html`<strong>${tx('dbu.nothing_changed')}</strong>` })}</p><p>${failed}</p>${at >= 0 ? html`<pre class="source">${maskSql([stmts[at]])}</pre>` : ''}</div>`);
383
387
  }
384
- flash(s, `${isNew ? 'Created' : 'Changed'} the role ${spec.name}: ${stmts.length} statement${stmts.length === 1 ? '' : 's'}.`);
388
+ flash(s, tx(isNew ? (stmts.length === 1 ? 'dbu.created_one' : 'dbu.created') : (stmts.length === 1 ? 'dbu.changed_one' : 'dbu.changed'), { name: spec.name, n: stmts.length }));
385
389
  return back(reply, s, isNew ? URL_BASE : roleUrl(spec.name));
386
390
  }
387
391
 
@@ -392,43 +396,45 @@ const reassignTargets = async (name: string) =>
392
396
  async function dropPage(req: Req, reply: FastifyReply, s: Session, name: string, o: { to?: string; errors?: string[]; output?: Raw | '' } = {}) {
393
397
  const r = await oneRole(name);
394
398
  if (!r) return reply.code(404).send('Not found');
395
- const refusal = r.pgkiln ? protectedMessage(r) : r.used_by.length ? `${r.used_by.join(', ')} use${r.used_by.length === 1 ? 's' : ''} ${r.name}: it isn't dropped while they do.` : '';
399
+ const tx = tr(s);
400
+ const refusal = r.pgkiln ? protectedMessage(tx, r) : r.used_by.length ? tx(r.used_by.length === 1 ? 'dbu.drop_refused_one' : 'dbu.drop_refused', { users: r.used_by.join(', '), name: r.name }) : '';
396
401
  const d = await details(r);
397
402
  const targets = await reassignTargets(name);
398
403
  const to = o.to !== undefined && (o.to === '' || targets.includes(o.to)) ? o.to : r.owned ? targets[0] ?? '' : '';
399
404
  const stmts = dropSql(r.name, to || null);
400
- const main = html`<h1 class="u-mb1 u-break">Drop role ${r.name}</h1>
405
+ const main = html`<h1 class="u-mb1 u-break">${tx('dbu.drop_title', { name: r.name })}</h1>
401
406
  ${refusal ? html`<div class="alert alert-error" role="alert">${refusal}</div>` : ''}${errorList(o.errors ?? [])}${o.output ?? ''}
402
- ${detailRegion(r, d)}
407
+ ${detailRegion(tx, r, d)}
403
408
  ${refusal ? '' : html`
404
- ${r.owned || r.owned_elsewhere ? html`<div class="alert alert-error" role="alert"><p><strong>${r.name} owns ${String(r.owned)} object${r.owned === 1 ? '' : 's'} in this database${r.owned_elsewhere ? html` and ${String(r.owned_elsewhere)} in other databases or shared (databases, tablespaces)` : ''}.</strong>
405
- Reassign them to another role, or drop them with the role.${r.owned_elsewhere ? ' Objects in other databases must be reassigned or dropped there first, or DROP ROLE fails.' : ''}</p></div>` : ''}
406
- ${region('Owned objects', html`<form method="get" action="${roleUrl(r.name)}/drop" class="form-grid">
407
- <div class="field"><label class="label" for="f_to">Objects it owns</label><select id="f_to" name="to">
408
- ${targets.map((t) => html`<option value="${t}"${sel(t === to)}>Reassign to ${t}</option>`)}
409
- <option value=""${sel(to === '')}>${r.owned ? 'Drop them with the role (DROP OWNED)' : 'It owns nothing here: just revoke its privileges'}</option></select>
410
- <small class="help">DROP OWNED also revokes the privileges granted to the role in this database.</small></div>
411
- <div class="buttons"><button class="btn">${icon('eye')} Review SQL</button></div></form>`)}
412
- ${region('SQL', html`<pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="Generated SQL">${maskSql(stmts)}</pre>
409
+ ${r.owned || r.owned_elsewhere ? html`<div class="alert alert-error" role="alert"><p><strong>${tx(r.owned === 1 ? 'dbu.owns_here_one' : 'dbu.owns_here', { name: r.name, n: r.owned })}${r.owned_elsewhere ? ` ${tx('dbu.owns_elsewhere', { n: r.owned_elsewhere })}` : ''}.</strong>
410
+ ${tx('dbu.reassign_note')}${r.owned_elsewhere ? ` ${tx('dbu.elsewhere_note')}` : ''}</p></div>` : ''}
411
+ ${region(tx('dbu.owned_objects'), html`<form method="get" action="${roleUrl(r.name)}/drop" class="form-grid">
412
+ <div class="field"><label class="label" for="f_to">${tx('dbu.objects_it_owns')}</label><select id="f_to" name="to">
413
+ ${targets.map((t) => html`<option value="${t}"${sel(t === to)}>${tx('dbu.reassign_to', { name: t })}</option>`)}
414
+ <option value=""${sel(to === '')}>${r.owned ? tx('dbu.drop_owned') : tx('dbu.owns_nothing')}</option></select>
415
+ <small class="help">${tx('dbu.drop_owned_help')}</small></div>
416
+ <div class="buttons"><button class="btn">${icon('eye')} ${tx('dbu.review')}</button></div></form>`)}
417
+ ${region(tx('dbu.sql'), html`<pre class="source dbu-sql" data-code-view="sql" tabindex="0" aria-label="${tx('dbu.generated_sql')}">${maskSql(stmts)}</pre>
413
418
  <form method="post" action="${URL_BASE}/drop" class="dbu-form">${csrf(s)}
414
419
  <input type="hidden" name="name" value="${r.name}"><input type="hidden" name="to" value="${to}"><input type="hidden" name="reviewed" value="${reviewDigest(stmts)}">
415
- ${!to && r.owned ? html`<label class="check u-mb1"><input type="checkbox" name="drop_objects" value="1" required> Drop the ${String(r.owned)} object${r.owned === 1 ? '' : 's'} ${r.name} owns, with their data</label>` : ''}
416
- <div class="buttons"><button class="btn btn-danger" data-confirm="Drop the role ${r.name}?">${icon('trash')} Drop role</button><a class="btn" href="${roleUrl(r.name)}">Cancel</a></div></form>`)}`}`;
417
- return send(reply.code(refusal ? 403 : o.errors?.length || o.output ? 422 : 200), s, shell(s, `Drop role ${r.name}`, crumbs([r.name, roleUrl(r.name)], ['Drop']), main, 'users'));
420
+ ${!to && r.owned ? html`<label class="check u-mb1"><input type="checkbox" name="drop_objects" value="1" required> ${tx(r.owned === 1 ? 'dbu.drop_objects_one' : 'dbu.drop_objects', { n: r.owned, name: r.name })}</label>` : ''}
421
+ <div class="buttons"><button class="btn btn-danger" data-confirm="${tx('dbu.drop_confirm', { name: r.name })}">${icon('trash')} ${tx('dbu.drop_btn')}</button><a class="btn" href="${roleUrl(r.name)}">${tx('dbu.cancel')}</a></div></form>`)}`}`;
422
+ return send(reply.code(refusal ? 403 : o.errors?.length || o.output ? 422 : 200), s, shell(s, tx('dbu.drop_title', { name: r.name }), crumbs(tx, [r.name, roleUrl(r.name)], [tx('dbu.crumb.drop')]), main, 'users'));
418
423
  }
419
424
 
420
425
  async function drop(req: Req, reply: FastifyReply, s: Session) {
421
426
  const b = req.body ?? {};
427
+ const tx = tr(s);
422
428
  const name = one(b.name);
423
429
  const to = one(b.to);
424
430
  const r = await oneRole(name);
425
431
  if (!r) return reply.code(404).send('Not found');
426
432
  if (r.pgkiln || r.used_by.length) return dropPage(req, reply, s, name, { to });
427
433
  const targets = await reassignTargets(name);
428
- if (to && !targets.includes(to)) return dropPage(req, reply, s, name, { errors: ['Choose a role to reassign the objects to.'] });
434
+ if (to && !targets.includes(to)) return dropPage(req, reply, s, name, { errors: [tx('dbu.choose_target')] });
429
435
  const stmts = dropSql(r.name, to || null);
430
- if (one(b.reviewed) !== reviewDigest(stmts)) return dropPage(req, reply, s, name, { to, errors: ['The statements changed since you reviewed them: review them again.'] });
431
- if (!to && r.owned && !b.drop_objects) return dropPage(req, reply, s, name, { to, errors: [`Tick the box to drop the objects ${r.name} owns, or reassign them.`] });
436
+ if (one(b.reviewed) !== reviewDigest(stmts)) return dropPage(req, reply, s, name, { to, errors: [tx('dbu.changed_since_drop')] });
437
+ if (!to && r.owned && !b.drop_objects) return dropPage(req, reply, s, name, { to, errors: [tx('dbu.tick_box', { name: r.name })] });
432
438
  let failed = '';
433
439
  try {
434
440
  await owner.tx(async (c) => {
@@ -439,8 +445,8 @@ async function drop(req: Req, reply: FastifyReply, s: Session) {
439
445
  failed = (e as Error).message;
440
446
  }
441
447
  await logActivity({ username: s.username, event: 'db_role', ip: clientIp(req), detail: `${failed ? 'failed: ' : ''}${maskSql(stmts)}`.slice(0, 4000) });
442
- if (failed) return dropPage(req, reply, s, name, { to, output: html`<div class="alert alert-error" role="alert"><p><strong>Nothing was changed</strong>: the statements ran in one transaction, which was rolled back.</p><p>${failed}</p></div>` });
443
- flash(s, `Dropped the role ${r.name}.`);
448
+ if (failed) return dropPage(req, reply, s, name, { to, output: html`<div class="alert alert-error" role="alert"><p>${tx.rich('dbu.rolled_back', { nothing: html`<strong>${tx('dbu.nothing_changed')}</strong>` })}</p><p>${failed}</p></div>` });
449
+ flash(s, tx('dbu.dropped', { name: r.name }));
444
450
  return back(reply, s, URL_BASE);
445
451
  }
446
452
 
@@ -257,61 +257,63 @@ export async function homeRoutes(app: FastifyInstance) {
257
257
  [currentWorkspace(s)],
258
258
  ),
259
259
  ]);
260
+ const tx = tr(s);
260
261
  const stat = (n: number | string, label: string) => html`<div class="stat"><b>${n}</b><span>${label}</span></div>`;
261
- const main = html`<h1 class="u-mb1">Workspace dashboard</h1>
262
+ const main = html`<h1 class="u-mb1">${tx('wd.title')}</h1>
262
263
  <div class="stat-grid">
263
- ${stat(totals.apps, 'applications')}${stat(totals.pages, 'pages')}${stat(totals.accounts, 'active accounts')}
264
- ${stat(totals.views, 'page views (24h)')}${stat(totals.users, 'distinct users (24h)')}
265
- ${stat(totals.failures, 'failed / locked sign-ins (24h)')}${stat(totals.problems, 'errors / access denied (24h)')}
264
+ ${stat(totals.apps, tx('wd.apps'))}${stat(totals.pages, tx('wd.pages'))}${stat(totals.accounts, tx('wd.accounts'))}
265
+ ${stat(totals.views, tx('wd.views'))}${stat(totals.users, tx('wd.users'))}
266
+ ${stat(totals.failures, tx('wd.failures'))}${stat(totals.problems, tx('wd.problems'))}
266
267
  </div>
267
- ${region('Applications (7 days)', html`<div class="table-wrap"><table class="report"><thead><tr><th scope="col">Application</th><th scope="col" class="num">Views (24h)</th><th scope="col" class="num">Views (7 days)</th><th scope="col" class="num">Users</th><th scope="col" class="num">Avg ms</th><th scope="col" class="num">Errors / denied</th><th scope="col"></th></tr></thead>
268
- <tbody>${perApp.rows.map((a) => html`<tr><td><a href="${BASE}/apps/${a.id}">${a.name}</a></td><td class="num">${a.views_day}</td><td class="num">${a.views_week}</td><td class="num">${a.users}</td><td class="num">${a.avg_ms}</td><td class="num">${a.problems}</td><td><a href="${BASE}/apps/${a.id}/activity">Activity</a></td></tr>`)}</tbody></table></div>`)}`;
269
- return send(reply, s, shell(s, 'Dashboard', [['App Builder', BASE], ['Dashboard']], main));
268
+ ${region(tx('wd.per_app'), html`<div class="table-wrap"><table class="report"><thead><tr><th scope="col">${tx('wd.col.app')}</th><th scope="col" class="num">${tx('wd.col.views_day')}</th><th scope="col" class="num">${tx('wd.col.views_week')}</th><th scope="col" class="num">${tx('wd.col.users')}</th><th scope="col" class="num">${tx('wd.col.avg')}</th><th scope="col" class="num">${tx('wd.col.problems')}</th><th scope="col"></th></tr></thead>
269
+ <tbody>${perApp.rows.map((a) => html`<tr><td><a href="${BASE}/apps/${a.id}">${a.name}</a></td><td class="num">${a.views_day}</td><td class="num">${a.views_week}</td><td class="num">${a.users}</td><td class="num">${a.avg_ms}</td><td class="num">${a.problems}</td><td><a href="${BASE}/apps/${a.id}/activity">${tx('wd.activity')}</a></td></tr>`)}</tbody></table></div>`)}`;
270
+ return send(reply, s, shell(s, tx('wd.crumb'), [[tx('rail.app_builder'), BASE], [tx('wd.crumb')]], main));
270
271
  });
271
272
 
272
273
  // ---------------------------------------------------------------- workspace utilities
273
274
  app.get(`${BASE}/utilities`, async (req: Req, reply) => {
274
275
  const s = await developer(req, reply);
275
276
  if (!s) return;
276
- const card = (href: string, ic: Raw | '', title: string, text: string) =>
277
+ const tx = tr(s);
278
+ const card = (href: string, ic: Raw | '', title: string, text: string | Raw) =>
277
279
  html`<a class="ab-tile" href="${href}"><span class="ab-tile-icon">${ic}</span><strong>${title}</strong><span>${text}</span></a>`;
278
- const main = html`<div class="ab-home"><div class="ab-main"><h1 class="u-mb1">Workspace utilities</h1>
280
+ const main = html`<div class="ab-home"><div class="ab-main"><h1 class="u-mb1">${tx('wu.title')}</h1>
279
281
  <div class="ab-tiles ab-tiles-wrap">
280
- ${card(`${BASE}/users`, icon('user'), 'Users', 'Accounts of the people who use the applications, and their access.')}
281
- ${card(`${BASE}/app-groups`, icon('folder'), 'Application groups', 'Organise the applications of this workspace into groups.')}
282
- ${card(`${BASE}/gallery`, icon('package'), 'App Gallery', 'Sample and starter applications to install, and this workspace\'s boilerplates.')}
283
- ${card(`${BASE}/users/providers`, icon('key'), 'Identity providers', 'OpenID Connect and SAML sign-in.')}
284
- ${card(`${BASE}/users/directories`, icon('org'), 'LDAP directories', 'Sign in with directory accounts.')}
285
- ${card(`${BASE}/users#account-settings`, icon('shield'), 'Password policy', 'Length, complexity and lifetime of passwords.')}
286
- ${card(`${BASE}/developers`, icon('users'), 'Developers', 'Who may use this builder.')}
287
- ${s.workspace?.sql_workshop === false ? '' : card(`${BASE}/sql`, icon('database'), 'SQL Workshop', 'Run SQL, browse objects and load data.')}
288
- ${card(`${BASE}/backups`, icon('archive'), 'Manage backups', 'The backups of every application of this workspace; back them all up at once.')}
289
- ${card(`${BASE}/workspace-files`, icon('file'), 'Static workspace files', 'CSS, JavaScript, images and fonts every application of this workspace can load (#WORKSPACE_FILES#).')}
290
- ${card(`${BASE}/remote-servers`, icon('server'), 'Remote servers', 'Named base URLs for REST data sources, catalogs and AI services: change one value to move them all.')}
291
- ${(await isAdmin(s.username)) ? card(`${BASE}/remote-databases`, icon('database'), 'Remote databases', 'Another PostgreSQL database\'s tables as foreign tables (postgres_fdw) the applications query like their own (administrators).') : ''}
292
- ${card(`${BASE}/workspace-credentials`, icon('key'), 'Web credentials', 'Keys and OAuth2 clients the applications of this workspace use by name (secrets encrypted, write-only).')}
293
- ${card(`${BASE}/rest-catalogs`, icon('globe'), 'REST Source Catalogs', 'Web services described once for the workspace (e.g. from an OpenAPI document); applications make REST data sources from them.')}
294
- ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), 'Export and import', 'This workspace\'s components as one file: settings, developers, groups, links, catalogs, scripts, blueprints (workspace administrators).') : ''}
295
- ${card(`${BASE}/dashboard`, icon('activity'), 'Dashboard', 'Usage and problems across the workspace.')}
296
- ${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), 'Instance settings', 'Session length, sign-in throttling and the server\'s configuration (administrators).') : ''}
282
+ ${card(`${BASE}/users`, icon('user'), tx('wu.users'), tx('wu.users_text'))}
283
+ ${card(`${BASE}/app-groups`, icon('folder'), tx('wu.app_groups'), tx('wu.app_groups_text'))}
284
+ ${card(`${BASE}/gallery`, icon('package'), tx('wu.gallery'), tx('wu.gallery_text'))}
285
+ ${card(`${BASE}/users/providers`, icon('key'), tx('wu.providers'), tx('wu.providers_text'))}
286
+ ${card(`${BASE}/users/directories`, icon('org'), tx('wu.ldap'), tx('wu.ldap_text'))}
287
+ ${card(`${BASE}/users#account-settings`, icon('shield'), tx('wu.password_policy'), tx('wu.password_policy_text'))}
288
+ ${card(`${BASE}/developers`, icon('users'), tx('wu.developers'), tx('wu.developers_text'))}
289
+ ${s.workspace?.sql_workshop === false ? '' : card(`${BASE}/sql`, icon('database'), tx('wu.sql'), tx('wu.sql_text'))}
290
+ ${card(`${BASE}/backups`, icon('archive'), tx('wu.backups'), tx('wu.backups_text'))}
291
+ ${card(`${BASE}/workspace-files`, icon('file'), tx('wu.files'), tx('wu.files_text', { tag: '#WORKSPACE_FILES#' }))}
292
+ ${card(`${BASE}/remote-servers`, icon('server'), tx('wu.servers'), tx('wu.servers_text'))}
293
+ ${(await isAdmin(s.username)) ? card(`${BASE}/remote-databases`, icon('database'), tx('wu.remote_dbs'), tx('wu.remote_dbs_text')) : ''}
294
+ ${card(`${BASE}/workspace-credentials`, icon('key'), tx('wu.credentials'), tx('wu.credentials_text'))}
295
+ ${card(`${BASE}/rest-catalogs`, icon('globe'), tx('wu.catalogs'), tx('wu.catalogs_text'))}
296
+ ${s.workspace?.admin ? card(`${BASE}/workspace-export`, icon('download'), tx('wu.export'), tx('wu.export_text')) : ''}
297
+ ${card(`${BASE}/dashboard`, icon('activity'), tx('wu.dashboard'), tx('wu.dashboard_text'))}
298
+ ${(await isAdmin(s.username)) ? card(`${BASE}/instance`, icon('settings'), tx('wu.instance'), tx('wu.instance_text')) : ''}
297
299
  ${(await isAdmin(s.username))
298
- ? card(`${BASE}/workspaces`, icon('layers'), 'Workspaces', 'Groups of applications and their developers (administrators).')
300
+ ? card(`${BASE}/workspaces`, icon('layers'), tx('wu.workspaces'), tx('wu.workspaces_text'))
299
301
  : s.workspace && (await workspaceAdmin(s.username, s.workspace.id))
300
- ? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), `Workspace ${s.workspace.name}`, 'Its name, developers, preferences and extension links (workspace administrators).')
302
+ ? card(`${BASE}/workspaces/${s.workspace.id}`, icon('layers'), tx('wu.workspace', { name: s.workspace.name }), tx('wu.workspace_text'))
301
303
  : ''}
302
- ${card(`${BASE}/ui-defaults`, icon('sliders-horizontal'), 'UI defaults', 'Labels, format masks, help and lists of values per table column, used by the page wizards.')}
303
- ${card(`${BASE}/icons`, icon('image'), 'Icons', 'Browse the icons of the icon picker.')}
304
- ${card(`${BASE}/vector-providers`, icon('search'), 'Vector providers', 'Embeddings for search by meaning: the applications\' vector providers, their vector columns, and whether pgvector is installed.')}
305
- ${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), 'AI services', 'Claude, OpenAI, Gemini and OpenAI-compatible services for the applications: models, keys, limits and usage (administrators).') : ''}
306
- ${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), 'Mail services', 'SMTP, Microsoft 365, Amazon SES, Resend, Postmark, SendGrid or Mailgun for the applications\' e-mail (administrators).') : ''}
307
- ${(await isAdmin(s.username)) ? card(`${BASE}/telemetry`, icon('activity'), 'OpenTelemetry', 'Traces, logs and metrics to your own monitoring through an OpenTelemetry Collector; /metrics for Prometheus (administrators).') : ''}
308
- ${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), 'Installation', 'Version, install and upgrade runs, applied migrations (administrators).') : ''}
304
+ ${card(`${BASE}/ui-defaults`, icon('sliders-horizontal'), tx('wu.ui_defaults'), tx('wu.ui_defaults_text'))}
305
+ ${card(`${BASE}/icons`, icon('image'), tx('wu.icons'), tx('wu.icons_text'))}
306
+ ${card(`${BASE}/vector-providers`, icon('search'), tx('wu.vectors'), tx('wu.vectors_text'))}
307
+ ${(await isAdmin(s.username)) ? card(`${BASE}/ai`, icon('bolt'), tx('wu.ai'), tx('wu.ai_text')) : ''}
308
+ ${(await isAdmin(s.username)) ? card(`${BASE}/mail`, icon('send'), tx('wu.mail'), tx('wu.mail_text')) : ''}
309
+ ${(await isAdmin(s.username)) ? card(`${BASE}/telemetry`, icon('activity'), tx('wu.otel'), tx('wu.otel_text')) : ''}
310
+ ${(await isAdmin(s.username)) ? card(`${BASE}/installation`, icon('history'), tx('wu.installation'), tx('wu.installation_text')) : ''}
309
311
  </div></div>
310
- <aside class="ab-side" aria-label="About and reports">
311
- <section><h2>About</h2><p>Workspace utilities hold what the applications of this workspace share: users and sign-in, groups, the gallery, and reports across the applications.</p>
312
- <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">Learn more …</a></section>
313
- <section><h2>Cross Application Reports</h2><ul class="ab-links ab-tasks">${crossAppLinks()}</ul></section>
312
+ <aside class="ab-side" aria-label="${tx('wu.side')}">
313
+ <section><h2>${tx('wu.about')}</h2><p>${tx('wu.about_text')}</p>
314
+ <a href="https://github.com/pgkiln/pgkiln/blob/main/docs/guide/03-builder.md#workspaces" target="_blank" rel="noopener">${tx('wu.learn_more')}</a></section>
315
+ <section><h2>${tx('wu.cross_app')}</h2><ul class="ab-links ab-tasks">${crossAppLinks()}</ul></section>
314
316
  </aside></div>`;
315
- return send(reply, s, shell(s, 'Workspace utilities', [['App Builder', BASE], ['Workspace utilities']], main));
317
+ return send(reply, s, shell(s, tx('wu.title'), [[tx('rail.app_builder'), BASE], [tx('wu.title')]], main));
316
318
  });
317
319
  }