pgkiln 0.58.0 → 0.59.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.
@@ -1,3 +1,4 @@
1
+ import { tr, translatorFor, type BuilderTranslate } from './i18n.ts';
1
2
  import type { FastifyInstance } from 'fastify';
2
3
  import { owner } from '../db.ts';
3
4
  import { appAllowed } from './workspaces.ts';
@@ -64,10 +65,10 @@ export async function roleHints(appIds: number[]): Promise<Map<number, RoleHint[
64
65
  }
65
66
 
66
67
  /** Clickable role suggestions under a roles field (JS adds the role; without JS they're a list). */
67
- export function roleHintsHtml(hints: RoleHint[], label = 'Roles this app checks') {
68
+ export function roleHintsHtml(hints: RoleHint[], label?: string, tx: BuilderTranslate = translatorFor('en')) {
68
69
  if (!hints.length)
69
- return html`<small class="help role-hints">This app doesn't check any roles yet. Roles only matter where an authorization scheme or <code>meta.has_role()</code> checks them (Shared Components → Authorization schemes).</small>`;
70
- return html`<small class="help role-hints">${label}: ${hints.map(
70
+ return html`<small class="help role-hints">${tx.rich('us.hints_none', { fn: html`<code>meta.has_role()</code>` })}</small>`;
71
+ return html`<small class="help role-hints">${label ?? tx('us.hints_label')}: ${hints.map(
71
72
  (h) => html`<button type="button" class="chip role-chip" data-add-role="${h.role}" title="${h.sources.join('; ')}">${h.role}</button> `,
72
73
  )}</small>`;
73
74
  }
@@ -100,6 +101,7 @@ export async function usersRoutes(app: FastifyInstance) {
100
101
  app.get(`${BASE}/users`, async (req: Req, reply) => {
101
102
  const s = await developer(req, reply);
102
103
  if (!s) return;
104
+ const tx = tr(s);
103
105
  const q = (req.query.q ?? '').trim();
104
106
  const users = (
105
107
  await owner.query(
@@ -114,46 +116,46 @@ export async function usersRoutes(app: FastifyInstance) {
114
116
  ).rows;
115
117
  const cfg = await accountSettings();
116
118
  const main = html`
117
- <div class="title-row"><h1>Users</h1><div class="buttons"><a class="btn" href="${BASE}/users/providers">Identity providers (single sign-on)</a> <a class="btn" href="${BASE}/users/directories">LDAP directories</a></div></div>
118
- <p class="muted u-mt0">One account per person. Give accounts access to applications, with roles per application, here or under an application's <b>Shared Components → Access control</b>.</p>
119
+ <div class="title-row"><h1>${tx('us.title')}</h1><div class="buttons"><a class="btn" href="${BASE}/users/providers">${tx('us.providers_btn')}</a> <a class="btn" href="${BASE}/users/directories">${tx('us.ldap_btn')}</a></div></div>
120
+ <p class="muted u-mt0">${tx.rich('us.intro', { where: html`<b>${tx('us.access_path')}</b>` })}</p>
119
121
  <div class="columns wide-left">
120
- ${region('Accounts', html`
122
+ ${region(tx('us.accounts'), html`
121
123
  <form method="get" class="search u-mb075 u-mwnone" role="search">
122
- <input type="search" name="q" value="${q}" placeholder="Search name, username or e-mail…" aria-label="Search accounts"><button class="btn">Search</button>
124
+ <input type="search" name="q" value="${q}" placeholder="${tx('us.search_ph')}" aria-label="${tx('us.search_label')}"><button class="btn">${tx('us.search')}</button>
123
125
  </form>
124
126
  <div class="table-wrap"><table class="report report-reflow">
125
- <thead><tr><th>Username</th><th>Name</th><th>Applications (roles)</th><th>Sign-in</th><th>Status</th></tr></thead>
127
+ <thead><tr><th>${tx('us.username')}</th><th>${tx('us.name')}</th><th>${tx('us.apps_roles')}</th><th>${tx('us.signin')}</th><th>${tx('us.status')}</th></tr></thead>
126
128
  <tbody>${users.length
127
129
  ? users.map((u) => html`<tr>
128
- <td data-label="Username"><a href="${BASE}/users/${u.id}">${u.username}</a></td>
129
- <td data-label="Name">${u.display_name ?? ''}${u.email ? html`<div class="muted">${u.email}</div>` : ''}</td>
130
- <td data-label="Applications">${u.apps.length ? u.apps.map((x: any) => html`<span class="chip">${x.alias}${x.roles.length ? `: ${x.roles.join(', ')}` : ''}</span> `) : html`<span class="muted">none</span>`}</td>
131
- <td data-label="Sign-in">${u.password_hash ? 'password' : html`<span class="muted">no password</span>`}</td>
132
- <td data-label="Status">${u.active ? 'active' : html`<b>inactive</b>`}${u.last_login_at ? html`<div class="muted">last ${String(u.last_login_at).slice(0, 16)}</div>` : ''}</td>
130
+ <td data-label="${tx('us.username')}"><a href="${BASE}/users/${u.id}">${u.username}</a></td>
131
+ <td data-label="${tx('us.name')}">${u.display_name ?? ''}${u.email ? html`<div class="muted">${u.email}</div>` : ''}</td>
132
+ <td data-label="${tx('us.applications')}">${u.apps.length ? u.apps.map((x: any) => html`<span class="chip">${x.alias}${x.roles.length ? `: ${x.roles.join(', ')}` : ''}</span> `) : html`<span class="muted">${tx('us.none')}</span>`}</td>
133
+ <td data-label="${tx('us.signin')}">${u.password_hash ? tx('us.password') : html`<span class="muted">${tx('us.no_password')}</span>`}</td>
134
+ <td data-label="${tx('us.status')}">${u.active ? tx('us.active') : html`<b>${tx('us.inactive')}</b>`}${u.last_login_at ? html`<div class="muted">${tx('us.last', { at: String(u.last_login_at).slice(0, 16) })}</div>` : ''}</td>
133
135
  </tr>`)
134
- : html`<tr><td colspan="5" class="empty">No accounts found.</td></tr>`}</tbody>
136
+ : html`<tr><td colspan="5" class="empty">${tx('us.no_accounts')}</td></tr>`}</tbody>
135
137
  </table></div>`)}
136
- ${region('Create account', html`
138
+ ${region(tx('us.create'), html`
137
139
  <form method="post" action="${BASE}/users">${csrf(s)}
138
- ${input('username', 'Username', '', { required: true, help: 'No spaces or colons; not case-sensitive.' })}
139
- ${input('display_name', 'Name', '')}
140
- ${input('email', 'E-mail', '', { type: 'email' })}
141
- ${input('password', 'Password', '', { type: 'password', auto: 'new-password', help: 'At least 8 characters. Leave empty for accounts that only sign in through single sign-on.' })}
142
- <div class="field"><label class="check"><input type="checkbox" name="must_change" value="true" checked> Require change of password on first use</label></div>
143
- <div class="buttons"><button class="btn btn-hot">Create account</button></div>
140
+ ${input('username', tx('us.username'), '', { required: true, help: tx('us.username_help') })}
141
+ ${input('display_name', tx('us.name'), '')}
142
+ ${input('email', tx('us.email'), '', { type: 'email' })}
143
+ ${input('password', tx('us.password_label'), '', { type: 'password', auto: 'new-password', help: tx('us.password_help') })}
144
+ <div class="field"><label class="check"><input type="checkbox" name="must_change" value="true" checked> ${tx('us.must_change')}</label></div>
145
+ <div class="buttons"><button class="btn btn-hot">${tx('us.create')}</button></div>
144
146
  </form>`)}
145
- ${region('Account settings', html`
147
+ ${region(tx('us.settings'), html`
146
148
  <form method="post" action="${BASE}/users/settings">${csrf(s)}
147
149
  <div class="form-grid">
148
- ${input('password_min_length', 'Minimum password length', cfg.minLength, { type: 'number' })}
149
- ${input('password_lifetime_days', 'Password lifetime (days)', cfg.lifetimeDays, { type: 'number', help: 'After this many days users must choose a new password at sign-in. 0 = never.' })}
150
+ ${input('password_min_length', tx('us.min_length'), cfg.minLength, { type: 'number' })}
151
+ ${input('password_lifetime_days', tx('us.lifetime'), cfg.lifetimeDays, { type: 'number', help: tx('us.lifetime_help') })}
150
152
  </div>
151
- <div class="field"><label class="check"><input type="checkbox" name="password_require_mixed" value="true"${cfg.requireMixed ? raw(' checked') : ''}> Passwords need letters and digits</label></div>
152
- <p class="muted">Passwords may never contain the username. Sign-in is locked for ${loginWindowMinutes()} minutes after ${loginMaxFailuresPerUser()} failed attempts (Workspace utilities → Instance settings); Unlock on an account lifts it.</p>
153
- <div class="buttons"><button class="btn">Save settings</button></div>
153
+ <div class="field"><label class="check"><input type="checkbox" name="password_require_mixed" value="true"${cfg.requireMixed ? raw(' checked') : ''}> ${tx('us.mixed')}</label></div>
154
+ <p class="muted">${tx('us.policy_note', { minutes: loginWindowMinutes(), failures: loginMaxFailuresPerUser() })}</p>
155
+ <div class="buttons"><button class="btn">${tx('us.save_settings')}</button></div>
154
156
  </form>`, '', 'account-settings')}
155
157
  </div>`;
156
- return send(reply, s, shell(s, 'Users', [['Users']], main, 'users'));
158
+ return send(reply, s, shell(s, tx('us.title'), [['Users']], main, 'users'));
157
159
  });
158
160
 
159
161
  app.post(`${BASE}/users`, async (req: Req, reply) => {
@@ -168,10 +170,10 @@ export async function usersRoutes(app: FastifyInstance) {
168
170
  values ($1, $2, $3, case when $4::text is null then null else meta.hash_password($4) end, $5 and $4::text is not null) returning id`,
169
171
  [b.username?.trim(), b.display_name?.trim() || null, b.email?.trim() || null, b.password || null, b.must_change === 'true'],
170
172
  );
171
- flash(s, 'Account created. Now give it access to applications.');
173
+ flash(s, tr(s)('us.created'));
172
174
  return back(reply, s, `${BASE}/users/${r.id}`);
173
175
  } catch (e) {
174
- flash(s, /account_username_key/.test((e as Error).message) ? 'An account with that username already exists.' : (e as Error).message, 'error');
176
+ flash(s, /account_username_key/.test((e as Error).message) ? tr(s)('us.exists') : (e as Error).message, 'error');
175
177
  return back(reply, s, `${BASE}/users`);
176
178
  }
177
179
  });
@@ -187,7 +189,7 @@ export async function usersRoutes(app: FastifyInstance) {
187
189
  [n(b.password_min_length, 6, 128), n(b.password_lifetime_days, 0, 3650), b.password_require_mixed === 'true' ? 'true' : 'false'],
188
190
  );
189
191
  clearAccountSettings();
190
- flash(s, 'Account settings saved.');
192
+ flash(s, tr(s)('us.settings_saved'));
191
193
  return back(reply, s, `${BASE}/users`);
192
194
  });
193
195
 
@@ -195,6 +197,7 @@ export async function usersRoutes(app: FastifyInstance) {
195
197
  app.get(`${BASE}/users/:id(^\\d+$)`, async (req: Req, reply) => {
196
198
  const s = await developer(req, reply);
197
199
  if (!s) return;
200
+ const tx = tr(s);
198
201
  const u = await owner.one('select *, meta.password_days_left(username) as days_left from meta.account where id = $1', [req.params.id]);
199
202
  if (!u) return reply.code(404).send('Not found');
200
203
  const fails = (await owner.one(
@@ -214,58 +217,58 @@ export async function usersRoutes(app: FastifyInstance) {
214
217
  const main = html`
215
218
  <div class="title-row"><h1>${u.username}</h1></div>
216
219
  <div class="columns">
217
- ${region('Details', html`
220
+ ${region(tx('us.details'), html`
218
221
  <form method="post" action="${BASE}/users/${u.id}">${csrf(s)}
219
222
  <div class="form-grid">
220
- ${input('display_name', 'Name', u.display_name)}
221
- ${input('email', 'E-mail', u.email, { type: 'email' })}
223
+ ${input('display_name', tx('us.name'), u.display_name)}
224
+ ${input('email', tx('us.email'), u.email, { type: 'email' })}
222
225
  </div>
223
- <div class="field u-mt075"><label class="check"><input type="checkbox" name="active" value="true"${u.active ? raw(' checked') : ''}> Active</label>
224
- <small class="help">Inactive accounts can't sign in to any application; deactivating ends their sessions.</small></div>
225
- <div class="buttons"><button class="btn btn-hot">Save</button></div>
226
+ <div class="field u-mt075"><label class="check"><input type="checkbox" name="active" value="true"${u.active ? raw(' checked') : ''}> ${tx('us.active_label')}</label>
227
+ <small class="help">${tx('us.active_help')}</small></div>
228
+ <div class="buttons"><button class="btn btn-hot">${tx('us.save')}</button></div>
226
229
  </form>
227
- <h3>Password</h3>
230
+ <h3>${tx('us.password_title')}</h3>
228
231
  <ul class="checklist">
229
- <li>${u.password_hash ? html`Set ${u.password_changed_at ? html`on ${String(u.password_changed_at).slice(0, 10)}` : ''}` : 'No password (single sign-on only)'}</li>
230
- ${u.must_change_password ? html`<li><b>Must be changed at the next sign-in</b></li>` : u.days_left !== null ? html`<li>Expires in ${u.days_left} day(s)</li>` : ''}
231
- ${fails ? html`<li>${fails} failed sign-in(s) in the last hour</li>` : ''}
232
+ <li>${u.password_hash ? (u.password_changed_at ? tx('us.set_on', { date: String(u.password_changed_at).slice(0, 10) }) : tx('us.set')) : tx('us.sso_only')}</li>
233
+ ${u.must_change_password ? html`<li><b>${tx('us.must_next')}</b></li>` : u.days_left !== null ? html`<li>${tx('us.expires', { n: u.days_left })}</li>` : ''}
234
+ ${fails ? html`<li>${tx('us.fails', { n: fails })}</li>` : ''}
232
235
  </ul>
233
236
  <div class="buttons">
234
- ${u.password_hash ? html`<form method="post" action="${BASE}/users/${u.id}/expire">${csrf(s)}<input type="hidden" name="expire" value="${u.must_change_password ? '0' : '1'}"><button class="btn">${u.must_change_password ? 'Unexpire password' : 'Expire password'}</button></form>` : ''}
235
- <form method="post" action="${BASE}/users/${u.id}/unlock">${csrf(s)}<button class="btn"${fails ? '' : raw(' disabled')}>Unlock sign-in</button></form>
237
+ ${u.password_hash ? html`<form method="post" action="${BASE}/users/${u.id}/expire">${csrf(s)}<input type="hidden" name="expire" value="${u.must_change_password ? '0' : '1'}"><button class="btn">${u.must_change_password ? tx('us.unexpire') : tx('us.expire')}</button></form>` : ''}
238
+ <form method="post" action="${BASE}/users/${u.id}/unlock">${csrf(s)}<button class="btn"${fails ? '' : raw(' disabled')}>${tx('us.unlock')}</button></form>
236
239
  </div>
237
240
  <form method="post" action="${BASE}/users/${u.id}/password" class="danger-zone">${csrf(s)}
238
- <div class="form-grid">${input('password', u.password_hash ? 'New password' : 'Set a password', '', { type: 'password', auto: 'new-password', help: 'Ends the account’s sessions.' })}</div>
239
- <div class="field"><label class="check"><input type="checkbox" name="must_change" value="true" checked> Require change of password on first use</label></div>
240
- <div class="buttons"><button class="btn">Set password</button>
241
- ${u.password_hash ? html`<button class="btn" name="remove" value="1" data-confirm="Remove the password? The account can then only sign in through single sign-on.">Remove password</button>` : ''}</div>
241
+ <div class="form-grid">${input('password', u.password_hash ? tx('us.new_password') : tx('us.set_password_label'), '', { type: 'password', auto: 'new-password', help: tx('us.ends_sessions') })}</div>
242
+ <div class="field"><label class="check"><input type="checkbox" name="must_change" value="true" checked> ${tx('us.must_change')}</label></div>
243
+ <div class="buttons"><button class="btn">${tx('us.set_password')}</button>
244
+ ${u.password_hash ? html`<button class="btn" name="remove" value="1" data-confirm="${tx('us.remove_confirm')}">${tx('us.remove_password')}</button>` : ''}</div>
242
245
  </form>
243
246
  <form method="post" action="${BASE}/users/${u.id}/delete" class="danger-zone">${csrf(s)}
244
- <button class="btn btn-danger" data-confirm="Delete account ${u.username} and all its application access?">Delete account</button>
247
+ <button class="btn btn-danger" data-confirm="${tx('us.delete_confirm', { user: u.username })}">${tx('us.delete')}</button>
245
248
  </form>`)}
246
- ${region('Application access', html`
247
- <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>Application</th><th>Roles</th><th></th></tr></thead><tbody>
249
+ ${region(tx('us.app_access'), html`
250
+ <div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('us.application')}</th><th>${tx('us.roles')}</th><th></th></tr></thead><tbody>
248
251
  ${access.rows.length
249
252
  ? access.rows.map((a) => html`<tr>
250
- <td data-label="Application"><a href="${BASE}/apps/${a.app_id}/shared">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
251
- <td data-label="Roles"><form method="post" action="${BASE}/users/${u.id}/access/${a.app_id}" class="search roles-form u-m0 u-mwnone">${csrf(s)}
252
- <input name="roles" value="${a.roles.join(', ')}" aria-label="Roles in ${a.name}" placeholder="no roles (e.g. ${(hints.get(a.app_id) ?? []).slice(0, 2).map((h) => h.role).join(', ') || 'admin'})"><button class="btn">Save</button>
253
- ${roleHintsHtml(hints.get(a.app_id) ?? [])}</form></td>
254
- <td data-label=""><form method="post" action="${BASE}/users/${u.id}/access/${a.app_id}/revoke">${csrf(s)}<button class="link-button" data-confirm="Revoke access to ${a.name}?">Revoke</button></form></td>
253
+ <td data-label="${tx('us.application')}"><a href="${BASE}/apps/${a.app_id}/shared">${a.name}</a> <span class="muted">/a/${a.alias}</span></td>
254
+ <td data-label="${tx('us.roles')}"><form method="post" action="${BASE}/users/${u.id}/access/${a.app_id}" class="search roles-form u-m0 u-mwnone">${csrf(s)}
255
+ <input name="roles" value="${a.roles.join(', ')}" aria-label="${tx('us.roles_in', { app: a.name })}" placeholder="${tx('us.roles_ph', { example: (hints.get(a.app_id) ?? []).slice(0, 2).map((h) => h.role).join(', ') || 'admin' })}"><button class="btn">${tx('us.save')}</button>
256
+ ${roleHintsHtml(hints.get(a.app_id) ?? [], undefined, tx)}</form></td>
257
+ <td data-label=""><form method="post" action="${BASE}/users/${u.id}/access/${a.app_id}/revoke">${csrf(s)}<button class="link-button" data-confirm="${tx('us.revoke_confirm', { app: a.name })}">${tx('us.revoke')}</button></form></td>
255
258
  </tr>`)
256
- : html`<tr><td colspan="3" class="empty">No access to any application yet.</td></tr>`}
259
+ : html`<tr><td colspan="3" class="empty">${tx('us.no_access')}</td></tr>`}
257
260
  </tbody></table></div>
258
261
  ${apps.rows.length
259
- ? html`<h3>Grant access</h3>
262
+ ? html`<h3>${tx('us.grant')}</h3>
260
263
  <form method="post" action="${BASE}/users/${u.id}/access">${csrf(s)}
261
264
  <div class="form-grid">
262
- ${select('app_id', 'Application', '', apps.rows.map((a): [string, string] => [String(a.id), a.name]))}
263
- <div class="field"><label class="label" for="f_roles">Roles</label>
264
- <input id="f_roles" name="roles" placeholder="comma separated, or pick below">
265
- ${apps.rows.map((a) => html`<div data-hints-for="${a.id}">${roleHintsHtml(hints.get(a.id) ?? [], `${a.name} checks`)}</div>`)}
265
+ ${select('app_id', tx('us.application'), '', apps.rows.map((a): [string, string] => [String(a.id), a.name]))}
266
+ <div class="field"><label class="label" for="f_roles">${tx('us.roles')}</label>
267
+ <input id="f_roles" name="roles" placeholder="${tx('us.roles_comma')}">
268
+ ${apps.rows.map((a) => html`<div data-hints-for="${a.id}">${roleHintsHtml(hints.get(a.id) ?? [], tx('us.hints_app', { app: a.name }), tx)}</div>`)}
266
269
  </div>
267
270
  </div>
268
- <div class="buttons"><button class="btn btn-hot">Grant access</button></div>
271
+ <div class="buttons"><button class="btn btn-hot">${tx('us.grant')}</button></div>
269
272
  </form>`
270
273
  : ''}`)}
271
274
  </div>`;
@@ -280,7 +283,7 @@ export async function usersRoutes(app: FastifyInstance) {
280
283
  req.params.id, b.display_name?.trim() || null, b.email?.trim() || null, b.active === 'true',
281
284
  ]);
282
285
  if (b.active !== 'true') await endSessions(req.params.id);
283
- flash(s, 'Account saved.');
286
+ flash(s, tr(s)('us.saved'));
284
287
  return back(reply, s, `${BASE}/users/${req.params.id}`);
285
288
  });
286
289
 
@@ -297,7 +300,7 @@ export async function usersRoutes(app: FastifyInstance) {
297
300
  await owner.query('select meta.set_password($1, $2, $3)', [acc?.username, b.password, b.must_change === 'true']);
298
301
  }
299
302
  await endSessions(req.params.id);
300
- flash(s, b.remove === '1' ? 'Password removed.' : 'Password set.');
303
+ flash(s, tr(s)(b.remove === '1' ? 'us.pw_removed' : 'us.pw_set'));
301
304
  } catch (e) {
302
305
  flash(s, (e as Error).message, 'error');
303
306
  }
@@ -309,7 +312,7 @@ export async function usersRoutes(app: FastifyInstance) {
309
312
  if (!s) return;
310
313
  const acc = await owner.one('select username from meta.account where id = $1', [req.params.id]);
311
314
  if (acc) await owner.query(req.body?.expire === '1' ? 'select meta.expire_password($1)' : 'select meta.unexpire_password($1)', [acc.username]);
312
- flash(s, req.body?.expire === '1' ? 'Password expired: it must be changed at the next sign-in.' : 'Password no longer expired.');
315
+ flash(s, tr(s)(req.body?.expire === '1' ? 'us.pw_expired' : 'us.pw_unexpired'));
313
316
  return back(reply, s, `${BASE}/users/${req.params.id}`);
314
317
  });
315
318
 
@@ -318,7 +321,7 @@ export async function usersRoutes(app: FastifyInstance) {
318
321
  if (!s) return;
319
322
  const acc = await owner.one('select username from meta.account where id = $1', [req.params.id]);
320
323
  if (acc) await owner.query(`insert into meta.activity_log (username, event, detail) values ($1, 'login_unlocked', $2)`, [acc.username, `by ${s.username}`]);
321
- flash(s, 'Sign-in unlocked.');
324
+ flash(s, tr(s)('us.unlocked'));
322
325
  return back(reply, s, `${BASE}/users/${req.params.id}`);
323
326
  });
324
327
 
@@ -327,7 +330,7 @@ export async function usersRoutes(app: FastifyInstance) {
327
330
  if (!s) return;
328
331
  await endSessions(req.params.id);
329
332
  await owner.query('delete from meta.account where id = $1', [req.params.id]);
330
- flash(s, 'Account deleted.');
333
+ flash(s, tr(s)('us.deleted'));
331
334
  return back(reply, s, `${BASE}/users`);
332
335
  });
333
336
 
@@ -337,7 +340,7 @@ export async function usersRoutes(app: FastifyInstance) {
337
340
  // only applications of the developer's workspaces (workspaces.ts)
338
341
  if (!/^\d{1,9}$/.test(String(req.body?.app_id ?? '')) || !(await appAllowed(s, req.body?.app_id ?? ''))) return reply.code(404).send('Not found');
339
342
  await grantAccess(req.body?.app_id ?? '', req.params.id, splitRoles(req.body?.roles));
340
- flash(s, 'Access granted.');
343
+ flash(s, tr(s)('us.granted'));
341
344
  return back(reply, s, `${BASE}/users/${req.params.id}`);
342
345
  });
343
346
 
@@ -347,7 +350,7 @@ export async function usersRoutes(app: FastifyInstance) {
347
350
  // only applications of the developer's workspaces (workspaces.ts)
348
351
  if (!/^\d{1,9}$/.test(String(req.params.appId)) || !(await appAllowed(s, req.params.appId))) return reply.code(404).send('Not found');
349
352
  await grantAccess(req.params.appId, req.params.id, splitRoles(req.body?.roles));
350
- flash(s, 'Roles saved. The user gets them at the next sign-in.');
353
+ flash(s, tr(s)('us.roles_saved'));
351
354
  return back(reply, s, `${BASE}/users/${req.params.id}`);
352
355
  });
353
356
 
@@ -358,35 +361,35 @@ export async function usersRoutes(app: FastifyInstance) {
358
361
  if (!/^\d{1,9}$/.test(String(req.params.appId)) || !(await appAllowed(s, req.params.appId))) return reply.code(404).send('Not found');
359
362
  await owner.query('delete from meta.app_access where account_id = $1 and app_id = $2', [req.params.id, req.params.appId]);
360
363
  await endSessions(req.params.id, req.params.appId);
361
- flash(s, 'Access revoked.');
364
+ flash(s, tr(s)('us.revoked'));
362
365
  return back(reply, s, `${BASE}/users/${req.params.id}`);
363
366
  });
364
367
 
365
368
  // ---------------------------------------------------------------- identity providers (OpenID Connect)
366
- const providerForm = (pr: any, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean) => html`
369
+ const providerForm = (pr: any, action: string, csrfField: ReturnType<typeof csrf>, isNew: boolean, tx: BuilderTranslate) => html`
367
370
  <form method="post" action="${action}">${csrfField}
368
371
  <div class="form-grid">
369
- ${isNew ? input('name', 'Name (in URLs)', '', { required: true, help: 'lowercase, e.g. entra, google, keycloak' }) : ''}
370
- ${isNew ? select('protocol', 'Protocol', 'oidc', [['oidc', 'OpenID Connect'], ['saml', 'SAML 2.0']]) : html`<div class="field"><span class="label">Protocol</span><span>${pr.protocol === 'saml' ? 'SAML 2.0' : 'OpenID Connect'}</span></div>`}
371
- ${input('display_name', 'Button label', pr.display_name, { required: true, help: 'Shown as "Sign in with …"' })}
372
- ${input('issuer', 'Issuer (OIDC: URL; SAML: the IdP entity ID)', pr.issuer, { required: true, help: 'e.g. https://login.microsoftonline.com/<tenant>/v2.0, https://keycloak.example.com/realms/acme, or for SAML the entityID of the IdP metadata' })}
373
- ${input('client_id', 'Client ID (SAML: pgkiln\'s entity ID)', pr.client_id, { help: 'OIDC: required. SAML: empty = the metadata URL of this provider.' })}
374
- ${input('idp_sso_url', 'SAML: IdP sign-in URL', pr.idp_sso_url, { type: 'url', help: 'The SingleSignOnService location (HTTP-Redirect binding) from the IdP metadata.' })}
375
- <div class="field" data-wide><label class="label" for="f_idp_cert">SAML: IdP signing certificate (PEM)</label>
372
+ ${isNew ? input('name', tx('idp.name'), '', { required: true, help: tx('idp.name_help') }) : ''}
373
+ ${isNew ? select('protocol', tx('idp.protocol'), 'oidc', [['oidc', 'OpenID Connect'], ['saml', 'SAML 2.0']]) : html`<div class="field"><span class="label">${tx('idp.protocol')}</span><span>${pr.protocol === 'saml' ? 'SAML 2.0' : 'OpenID Connect'}</span></div>`}
374
+ ${input('display_name', tx('idp.label'), pr.display_name, { required: true, help: tx('idp.label_help') })}
375
+ ${input('issuer', tx('idp.issuer'), pr.issuer, { required: true, help: tx('idp.issuer_help') })}
376
+ ${input('client_id', tx('idp.client_id'), pr.client_id, { help: tx('idp.client_id_help') })}
377
+ ${input('idp_sso_url', tx('idp.sso_url'), pr.idp_sso_url, { type: 'url', help: tx('idp.sso_url_help') })}
378
+ <div class="field" data-wide><label class="label" for="f_idp_cert">${tx('idp.cert')}</label>
376
379
  <textarea id="f_idp_cert" name="idp_cert" rows="4" placeholder="-----BEGIN CERTIFICATE-----">${pr.idp_cert ?? ''}</textarea>
377
- <small class="help">From the IdP metadata (X509Certificate, wrapped in BEGIN/END CERTIFICATE lines). Assertions must be signed with it.</small></div>
378
- ${input('client_secret', 'Client secret', '', { type: 'password', auto: 'new-password', help: pr.has_secret ? 'A secret is stored. Leave empty to keep it.' : 'Leave empty for a public client (PKCE only).' })}
379
- ${input('scopes', 'Scopes', pr.scopes ?? 'openid profile email')}
380
- ${input('username_claim', 'Username claim', pr.username_claim ?? 'preferred_username', { help: 'Use a claim users cannot change themselves (e.g. preferred_username, upn, email). SAML: an attribute name, or nameID.' })}
381
- ${input('groups_claim', 'Groups claim', pr.groups_claim ?? 'groups', { help: 'Dot paths work, e.g. realm_access.roles' })}
380
+ <small class="help">${tx('idp.cert_help')}</small></div>
381
+ ${input('client_secret', tx('idp.secret'), '', { type: 'password', auto: 'new-password', help: pr.has_secret ? tx('idp.secret_kept') : tx('idp.secret_public') })}
382
+ ${input('scopes', tx('idp.scopes'), pr.scopes ?? 'openid profile email')}
383
+ ${input('username_claim', tx('idp.username_claim'), pr.username_claim ?? 'preferred_username', { help: tx('idp.username_claim_help') })}
384
+ ${input('groups_claim', tx('idp.groups_claim'), pr.groups_claim ?? 'groups', { help: tx('idp.groups_claim_help') })}
382
385
  </div>
383
- <div class="field u-mt075"><label class="check"><input type="checkbox" name="auto_create" value="true"${pr.auto_create ? raw(' checked') : ''}> Create accounts automatically on first sign-in</label>
384
- <small class="help">Otherwise only people with an account linked to this provider (or allowed below) can sign in.</small></div>
385
- <div class="field"><label class="check"><input type="checkbox" name="link_existing" value="true"${pr.link_existing ? raw(' checked') : ''}> Link existing accounts with the same username on their first sign-in</label>
386
- <small class="help">Only when users can't choose the username claim themselves at this provider: otherwise someone could register the name of an existing account and take it over. With the e-mail claim, only verified addresses link. Turn it off once the accounts are linked.</small></div>
387
- <div class="field"><label class="check"><input type="checkbox" name="enabled" value="true"${pr.enabled !== false ? raw(' checked') : ''}> Enabled</label></div>
388
- ${!isNew && pr.has_secret ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_secret" value="true"> Remove the stored client secret</label></div>` : ''}
389
- <div class="buttons"><button class="btn btn-hot">${isNew ? 'Add provider' : 'Save'}</button></div>
386
+ <div class="field u-mt075"><label class="check"><input type="checkbox" name="auto_create" value="true"${pr.auto_create ? raw(' checked') : ''}> ${tx('idp.auto_create')}</label>
387
+ <small class="help">${tx('idp.auto_create_help')}</small></div>
388
+ <div class="field"><label class="check"><input type="checkbox" name="link_existing" value="true"${pr.link_existing ? raw(' checked') : ''}> ${tx('idp.link')}</label>
389
+ <small class="help">${tx('idp.link_help')}</small></div>
390
+ <div class="field"><label class="check"><input type="checkbox" name="enabled" value="true"${pr.enabled !== false ? raw(' checked') : ''}> ${tx('idp.enabled')}</label></div>
391
+ ${!isNew && pr.has_secret ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_secret" value="true"> ${tx('idp.remove_secret')}</label></div>` : ''}
392
+ <div class="buttons"><button class="btn btn-hot">${isNew ? tx('idp.add') : tx('us.save')}</button></div>
390
393
  </form>`;
391
394
 
392
395
  /** A PEM certificate: the base64 body alone (as in IdP metadata) gets its BEGIN/END lines. */
@@ -409,26 +412,27 @@ export async function usersRoutes(app: FastifyInstance) {
409
412
  app.get(`${BASE}/users/providers`, async (req: Req, reply) => {
410
413
  const s = await administrator(req, reply, 'Identity providers');
411
414
  if (!s) return;
415
+ const tx = tr(s);
412
416
  const rows = (await owner.query(`select id, name, display_name, issuer, enabled, auto_create, link_existing,
413
417
  (select count(*) from meta.account_identity i where i.provider_id = p.id)::int as linked,
414
418
  (select string_agg(a.alias, ', ' order by a.alias) from meta.app a where p.name = any(a.sso_providers)) as apps
415
419
  from meta.auth_provider p order by display_name`)).rows;
416
420
  const main = html`
417
- <div class="title-row"><h1>Identity providers</h1></div>
418
- <p class="muted u-mt0">OpenID Connect providers for single sign-on (Microsoft Entra ID, Google, Okta, Keycloak, Auth0, …). Register pgkiln at the provider as a web application with the redirect URI shown, then enable the provider per application under <b>Settings → Sign-in methods</b>.</p>
421
+ <div class="title-row"><h1>${tx('idp.title')}</h1></div>
422
+ <p class="muted u-mt0">${tx.rich('idp.intro', { where: html`<b>${tx('idp.where')}</b>` })}</p>
419
423
  <div class="columns wide-left">
420
- ${region('Providers', html`<div class="table-wrap"><table class="report report-reflow">
421
- <thead><tr><th>Provider</th><th>Issuer</th><th>Used by</th><th>Linked accounts</th><th>Status</th></tr></thead>
424
+ ${region(tx('idp.providers'), html`<div class="table-wrap"><table class="report report-reflow">
425
+ <thead><tr><th>${tx('idp.provider')}</th><th>${tx('idp.issuer_col')}</th><th>${tx('idp.used_by')}</th><th>${tx('idp.linked')}</th><th>${tx('us.status')}</th></tr></thead>
422
426
  <tbody>${rows.length ? rows.map((r) => html`<tr>
423
- <td data-label="Provider"><a href="${BASE}/users/providers/${r.id}">${r.display_name}</a> <span class="muted">${r.name}</span></td>
424
- <td data-label="Issuer">${r.issuer}</td>
425
- <td data-label="Used by">${r.apps ?? html`<span class="muted">no apps</span>`}</td>
426
- <td data-label="Linked accounts">${r.linked}</td>
427
- <td data-label="Status">${r.enabled ? 'enabled' : html`<b>disabled</b>`}${r.auto_create ? ' · auto-create' : ''}${r.link_existing ? ' · links existing accounts' : ''}</td>
428
- </tr>`) : html`<tr><td colspan="5" class="empty">No identity providers yet.</td></tr>`}</tbody></table></div>`)}
429
- ${region('Add provider', providerForm({}, `${BASE}/users/providers`, csrf(s), true))}
427
+ <td data-label="${tx('idp.provider')}"><a href="${BASE}/users/providers/${r.id}">${r.display_name}</a> <span class="muted">${r.name}</span></td>
428
+ <td data-label="${tx('idp.issuer_col')}">${r.issuer}</td>
429
+ <td data-label="${tx('idp.used_by')}">${r.apps ?? html`<span class="muted">${tx('idp.no_apps')}</span>`}</td>
430
+ <td data-label="${tx('idp.linked')}">${r.linked}</td>
431
+ <td data-label="${tx('us.status')}">${r.enabled ? tx('idp.enabled_s') : html`<b>${tx('idp.disabled_s')}</b>`}${r.auto_create ? ` · ${tx('idp.auto_s')}` : ''}${r.link_existing ? ` · ${tx('idp.links_s')}` : ''}</td>
432
+ </tr>`) : html`<tr><td colspan="5" class="empty">${tx('idp.none')}</td></tr>`}</tbody></table></div>`)}
433
+ ${region(tx('idp.add'), providerForm({}, `${BASE}/users/providers`, csrf(s), true, tx))}
430
434
  </div>`;
431
- return send(reply, s, shell(s, 'Identity providers', [['Users', `${BASE}/users`], ['Identity providers']], main, 'users'));
435
+ return send(reply, s, shell(s, tx('idp.title'), [['Users', `${BASE}/users`], [tx('idp.title')]], main, 'users'));
432
436
  });
433
437
 
434
438
  app.post(`${BASE}/users/providers`, async (req: Req, reply) => {
@@ -441,7 +445,7 @@ export async function usersRoutes(app: FastifyInstance) {
441
445
  values ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) returning id`,
442
446
  [b.name?.trim().toLowerCase(), ...providerValues(b, protocolOf(b.protocol), b.name?.trim().toLowerCase() ?? ''), protocolOf(b.protocol), b.client_secret || null],
443
447
  );
444
- flash(s, 'Provider added. Register the redirect URI at the provider, then test the connection.');
448
+ flash(s, tr(s)('idp.added'));
445
449
  return back(reply, s, `${BASE}/users/providers/${r.id}`);
446
450
  } catch (e) {
447
451
  flash(s, (e as Error).message, 'error');
@@ -452,27 +456,28 @@ export async function usersRoutes(app: FastifyInstance) {
452
456
  app.get(`${BASE}/users/providers/:id`, async (req: Req, reply) => {
453
457
  const s = await administrator(req, reply, 'Identity providers');
454
458
  if (!s) return;
459
+ const tx = tr(s);
455
460
  const pr = await owner.one('select *, client_secret is not null as has_secret from meta.auth_provider where id = $1', [req.params.id]);
456
461
  if (!pr) return reply.code(404).send('Not found');
457
462
  delete pr.client_secret; // never sent to the browser
458
463
  const main = html`
459
464
  <div class="title-row"><h1>${pr.display_name}</h1></div>
460
465
  <div class="columns wide-left">
461
- ${region('Settings', html`${providerForm(pr, `${BASE}/users/providers/${pr.id}`, csrf(s), false)}
466
+ ${region(tx('idp.settings'), html`${providerForm(pr, `${BASE}/users/providers/${pr.id}`, csrf(s), false, tx)}
462
467
  <form method="post" action="${BASE}/users/providers/${pr.id}/delete" class="danger-zone">${csrf(s)}
463
- <button class="btn btn-danger" data-confirm="Delete ${pr.display_name}? Linked identities are removed; accounts stay.">Delete provider</button></form>`)}
464
- ${pr.protocol === 'saml' ? region('Register at the identity provider', html`
465
- <p>Service provider metadata (import it at the IdP):</p>
468
+ <button class="btn btn-danger" data-confirm="${tx('idp.delete_confirm', { name: pr.display_name })}">${tx('idp.delete')}</button></form>`)}
469
+ ${pr.protocol === 'saml' ? region(tx('idp.register_idp'), html`
470
+ <p>${tx('idp.sp_metadata')}</p>
466
471
  <p><a href="${metadataUrl(pr)}"><code>${metadataUrl(pr)}</code></a></p>
467
- <p>Or by hand: entity ID <code>${spEntityId(pr)}</code>, assertion consumer service (HTTP-POST) <code>${acsUrl(pr)}</code>.</p>
468
- <p class="muted">Based on <code>PUBLIC_URL</code>. Sign the assertions; send the groups in the <code>${pr.groups_claim}</code> attribute to map them to roles.</p>`) : region('Register at the provider', html`
469
- <p>Redirect URI (callback):</p>
472
+ <p>${tx.rich('idp.by_hand', { entity: html`<code>${spEntityId(pr)}</code>`, acs: html`<code>${acsUrl(pr)}</code>` })}</p>
473
+ <p class="muted">${tx.rich('idp.saml_note', { env: html`<code>PUBLIC_URL</code>`, claim: html`<code>${pr.groups_claim}</code>` })}</p>`) : region(tx('idp.register'), html`
474
+ <p>${tx('idp.redirect')}</p>
470
475
  <p><code>${redirectUri(pr)}</code></p>
471
- <p class="muted">Based on <code>PUBLIC_URL</code>; set it to the address users see (e.g. https://apps.example.com).</p>
472
- <p>Grant type: <b>authorization code</b> with PKCE. Scopes: <code>${pr.scopes}</code>. Include a <code>${pr.groups_claim}</code> claim in the ID token to map groups to roles.</p>
473
- <form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn">Test discovery</button></form>`)}
476
+ <p class="muted">${tx.rich('idp.public_url', { env: html`<code>PUBLIC_URL</code>` })}</p>
477
+ <p>${tx.rich('idp.grant', { type: html`<b>${tx('idp.auth_code')}</b>`, scopes: html`<code>${pr.scopes}</code>`, claim: html`<code>${pr.groups_claim}</code>` })}</p>
478
+ <form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn">${tx('idp.test')}</button></form>`)}
474
479
  </div>`;
475
- return send(reply, s, shell(s, pr.display_name, [['Users', `${BASE}/users`], ['Identity providers', `${BASE}/users/providers`], [pr.display_name]], main, 'users'));
480
+ return send(reply, s, shell(s, pr.display_name, [['Users', `${BASE}/users`], [tx('idp.title'), `${BASE}/users/providers`], [pr.display_name]], main, 'users'));
476
481
  });
477
482
 
478
483
  app.post(`${BASE}/users/providers/:id`, async (req: Req, reply) => {
@@ -489,7 +494,7 @@ export async function usersRoutes(app: FastifyInstance) {
489
494
  where id = $1`,
490
495
  [req.params.id, ...providerValues(b, current.protocol, current.name), b.client_secret || null, b.remove_secret === 'true'],
491
496
  );
492
- flash(s, 'Provider saved.');
497
+ flash(s, tr(s)('idp.saved'));
493
498
  } catch (e) {
494
499
  flash(s, (e as Error).message, 'error');
495
500
  }
@@ -502,9 +507,9 @@ export async function usersRoutes(app: FastifyInstance) {
502
507
  const pr = await owner.one('select issuer from meta.auth_provider where id = $1', [req.params.id]);
503
508
  try {
504
509
  const doc = await discover(pr.issuer);
505
- flash(s, `Discovery works. Authorization endpoint: ${doc.authorization_endpoint}`);
510
+ flash(s, tr(s)('idp.works', { url: doc.authorization_endpoint }));
506
511
  } catch (e) {
507
- flash(s, `Discovery failed: ${(e as Error).message}`, 'error');
512
+ flash(s, tr(s)('idp.failed', { error: (e as Error).message }), 'error');
508
513
  }
509
514
  return back(reply, s, `${BASE}/users/providers/${req.params.id}`);
510
515
  });
@@ -513,7 +518,7 @@ export async function usersRoutes(app: FastifyInstance) {
513
518
  const s = await administrator(req, reply, 'Identity providers');
514
519
  if (!s) return;
515
520
  await owner.query('delete from meta.auth_provider where id = $1', [req.params.id]);
516
- flash(s, 'Provider deleted.');
521
+ flash(s, tr(s)('idp.deleted'));
517
522
  return back(reply, s, `${BASE}/users/providers`);
518
523
  });
519
524
  }