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.
- package/docs/apex-feature-parity.md +1 -1
- package/docs/guide/03-builder.md +3 -2
- package/package.json +1 -1
- package/src/builder/i18n/de.ts +328 -0
- package/src/builder/i18n/en.ts +328 -0
- package/src/builder/i18n/es.ts +328 -0
- package/src/builder/i18n/fr.ts +328 -0
- package/src/builder/i18n/nl.ts +328 -0
- package/src/builder/i18n/pt.ts +328 -0
- package/src/builder/routes.ts +31 -30
- package/src/builder/shared.ts +2 -2
- package/src/builder/users.ts +123 -118
- package/src/builder/workspaces.ts +143 -135
package/src/builder/users.ts
CHANGED
|
@@ -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
|
|
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"
|
|
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
|
|
118
|
-
<p class="muted u-mt0"
|
|
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('
|
|
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="
|
|
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
|
|
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="
|
|
129
|
-
<td data-label="
|
|
130
|
-
<td data-label="
|
|
131
|
-
<td data-label="
|
|
132
|
-
<td data-label="
|
|
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"
|
|
136
|
+
: html`<tr><td colspan="5" class="empty">${tx('us.no_accounts')}</td></tr>`}</tbody>
|
|
135
137
|
</table></div>`)}
|
|
136
|
-
${region('
|
|
138
|
+
${region(tx('us.create'), html`
|
|
137
139
|
<form method="post" action="${BASE}/users">${csrf(s)}
|
|
138
|
-
${input('username', '
|
|
139
|
-
${input('display_name', '
|
|
140
|
-
${input('email', '
|
|
141
|
-
${input('password', '
|
|
142
|
-
<div class="field"><label class="check"><input type="checkbox" name="must_change" value="true" checked>
|
|
143
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
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('
|
|
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', '
|
|
149
|
-
${input('password_lifetime_days', '
|
|
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') : ''}>
|
|
152
|
-
<p class="muted"
|
|
153
|
-
<div class="buttons"><button class="btn"
|
|
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, '
|
|
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, '
|
|
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) ? '
|
|
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, '
|
|
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('
|
|
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', '
|
|
221
|
-
${input('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') : ''}>
|
|
224
|
-
<small class="help"
|
|
225
|
-
<div class="buttons"><button class="btn btn-hot"
|
|
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
|
|
230
|
+
<h3>${tx('us.password_title')}</h3>
|
|
228
231
|
<ul class="checklist">
|
|
229
|
-
<li>${u.password_hash ?
|
|
230
|
-
${u.must_change_password ? html`<li><b
|
|
231
|
-
${fails ? html`<li>${fails
|
|
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 ? '
|
|
235
|
-
<form method="post" action="${BASE}/users/${u.id}/unlock">${csrf(s)}<button class="btn"${fails ? '' : raw(' disabled')}
|
|
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 ? '
|
|
239
|
-
<div class="field"><label class="check"><input type="checkbox" name="must_change" value="true" checked>
|
|
240
|
-
<div class="buttons"><button class="btn"
|
|
241
|
-
${u.password_hash ? html`<button class="btn" name="remove" value="1" data-confirm="
|
|
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="
|
|
247
|
+
<button class="btn btn-danger" data-confirm="${tx('us.delete_confirm', { user: u.username })}">${tx('us.delete')}</button>
|
|
245
248
|
</form>`)}
|
|
246
|
-
${region('
|
|
247
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th
|
|
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="
|
|
251
|
-
<td data-label="
|
|
252
|
-
<input name="roles" value="${a.roles.join(', ')}" aria-label="
|
|
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="
|
|
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"
|
|
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
|
|
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', '
|
|
263
|
-
<div class="field"><label class="label" for="f_roles"
|
|
264
|
-
<input id="f_roles" name="roles" placeholder="
|
|
265
|
-
${apps.rows.map((a) => html`<div data-hints-for="${a.id}">${roleHintsHtml(hints.get(a.id) ?? [],
|
|
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"
|
|
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, '
|
|
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' ? '
|
|
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' ? '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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, '
|
|
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', '
|
|
370
|
-
${isNew ? select('protocol', '
|
|
371
|
-
${input('display_name', '
|
|
372
|
-
${input('issuer', '
|
|
373
|
-
${input('client_id',
|
|
374
|
-
${input('idp_sso_url', '
|
|
375
|
-
<div class="field" data-wide><label class="label" for="f_idp_cert"
|
|
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"
|
|
378
|
-
${input('client_secret', '
|
|
379
|
-
${input('scopes', '
|
|
380
|
-
${input('username_claim', '
|
|
381
|
-
${input('groups_claim', '
|
|
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') : ''}>
|
|
384
|
-
<small class="help"
|
|
385
|
-
<div class="field"><label class="check"><input type="checkbox" name="link_existing" value="true"${pr.link_existing ? raw(' checked') : ''}>
|
|
386
|
-
<small class="help"
|
|
387
|
-
<div class="field"><label class="check"><input type="checkbox" name="enabled" value="true"${pr.enabled !== false ? raw(' checked') : ''}>
|
|
388
|
-
${!isNew && pr.has_secret ? html`<div class="field"><label class="check"><input type="checkbox" name="remove_secret" value="true">
|
|
389
|
-
<div class="buttons"><button class="btn btn-hot">${isNew ? '
|
|
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
|
|
418
|
-
<p class="muted u-mt0"
|
|
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('
|
|
421
|
-
<thead><tr><th
|
|
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="
|
|
424
|
-
<td data-label="
|
|
425
|
-
<td data-label="
|
|
426
|
-
<td data-label="
|
|
427
|
-
<td data-label="
|
|
428
|
-
</tr>`) : html`<tr><td colspan="5" class="empty"
|
|
429
|
-
${region('
|
|
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, '
|
|
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, '
|
|
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('
|
|
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="
|
|
464
|
-
${pr.protocol === 'saml' ? region('
|
|
465
|
-
<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
|
|
468
|
-
<p class="muted"
|
|
469
|
-
<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"
|
|
472
|
-
<p
|
|
473
|
-
<form method="post" action="${BASE}/users/providers/${pr.id}/test">${csrf(s)}<button class="btn"
|
|
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`], ['
|
|
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, '
|
|
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,
|
|
510
|
+
flash(s, tr(s)('idp.works', { url: doc.authorization_endpoint }));
|
|
506
511
|
} catch (e) {
|
|
507
|
-
flash(s,
|
|
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, '
|
|
521
|
+
flash(s, tr(s)('idp.deleted'));
|
|
517
522
|
return back(reply, s, `${BASE}/users/providers`);
|
|
518
523
|
});
|
|
519
524
|
}
|