pgkiln 0.59.0 → 0.61.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/README.md +3 -0
- package/SECURITY.md +3 -0
- package/db/migrations/128_developer_mfa.sql +48 -0
- package/db/migrations/129_developer_passkeys.sql +49 -0
- package/docs/apex-feature-parity.md +6 -3
- package/docs/guide/01-installation.md +2 -1
- package/docs/guide/03-builder.md +74 -0
- package/docs/guide/08-security.md +4 -0
- package/docs/guide/18-cli.md +9 -0
- package/docs/guide/20-ai-agents.md +18 -0
- package/package.json +1 -1
- package/public/builder.css +8 -0
- package/public/builder.js +61 -0
- package/src/builder/i18n/de.ts +97 -0
- package/src/builder/i18n/en.ts +97 -0
- package/src/builder/i18n/es.ts +97 -0
- package/src/builder/i18n/fr.ts +97 -0
- package/src/builder/i18n/nl.ts +97 -0
- package/src/builder/i18n/pt.ts +97 -0
- package/src/builder/instance.ts +2 -0
- package/src/builder/routes.ts +196 -8
- package/src/builder/security.ts +371 -0
- package/src/builder/ui.ts +13 -1
- package/src/cli/main.ts +51 -0
- package/src/instance.ts +2 -1
- package/src/mfa.ts +333 -0
- package/src/webauthn.ts +205 -0
package/src/builder/routes.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { appOr404 } from './forms.ts';
|
|
|
19
19
|
import { docToFiles, filesToZip } from '../appfiles.ts';
|
|
20
20
|
import { homeRoutes, rememberApp } from './home.ts';
|
|
21
21
|
import { currentWorkspace, placeApp, workshopDb, workspaceRoutes } from './workspaces.ts';
|
|
22
|
+
import { securityRoutes } from './security.ts';
|
|
22
23
|
import { instanceRoutes } from './instance.ts';
|
|
23
24
|
import { saveTimeZoneSettings, timeZoneSettings } from './globalization.ts';
|
|
24
25
|
import { WIZARD_KINDS, wizardRoutes, wizardTables } from './wizards.ts';
|
|
@@ -26,6 +27,9 @@ import { appFromFileRoutes } from './appfromfile.ts';
|
|
|
26
27
|
import { clearDevbar } from '../runtime/devbar.ts';
|
|
27
28
|
import { EMAIL } from './find-workspace.ts';
|
|
28
29
|
import { cookieLanguage, LANG_COOKIE, tr, translatorFor, validBuilderLanguage } from './i18n.ts';
|
|
30
|
+
import { checkSecondFactor, codesLeft, forgetBrowsers, hasFactor, isTrustedBrowser, leavesTooFewAdmins, passkeysOf, trustBrowser, trustDays, usePasskey } from '../mfa.ts';
|
|
31
|
+
import { giveChallenge, takeChallenge } from './security.ts';
|
|
32
|
+
import { relyingParty, verifyAssertion, WebAuthnError } from '../webauthn.ts';
|
|
29
33
|
import { chooseLanguage, langCookieOptions, languageRoutes, signInLanguageLinks } from './language.ts';
|
|
30
34
|
|
|
31
35
|
// Builder pages: sign-in, workspace and app home, settings, activity and
|
|
@@ -148,27 +152,197 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
148
152
|
flash(s, tx(workspace ? 'signin.invalid_ws' : 'signin.invalid'), 'error');
|
|
149
153
|
return back(reply, s, again);
|
|
150
154
|
}
|
|
155
|
+
// (sprint 126, SEC-2) a developer with a second factor: the password was right, the session comes after the code;
|
|
156
|
+
// (sprint 127) unless this browser was trusted after a second factor, for the instance's number of days
|
|
157
|
+
if ((await hasFactor(dev.username)) && !(await isTrustedBrowser(dev.username, req.cookies?.[TRUST_COOKIE]))) {
|
|
158
|
+
s.state.__MFA = JSON.stringify({ u: dev.username, ws: ws ? String(ws.id) : null, w: workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, at: Date.now() });
|
|
159
|
+
await saveState(s);
|
|
160
|
+
return reply.redirect(`${BASE}/login/2fa`, 303);
|
|
161
|
+
}
|
|
162
|
+
return finishSignIn(req, reply, s, { username: dev.username, language: dev.language }, { ws: ws ? String(ws.id) : null, workspace, next, remember: req.body?.remember === '1', weak: password === 'admin' || password === dev.username, picked, detail: 'builder' });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
/** The session after a successful sign-in (password, and the second factor when the developer has one). */
|
|
166
|
+
async function finishSignIn(req: Req, reply: Parameters<typeof back>[0], s: Awaited<ReturnType<typeof getSession>>, dev: { username: string; language: string | null },
|
|
167
|
+
o: { ws: string | null; workspace: string; next: string; remember: boolean; weak: boolean; picked: ReturnType<typeof validBuilderLanguage>; detail: string }) {
|
|
168
|
+
const ip = clientIp(req);
|
|
169
|
+
const { picked, next, workspace } = o;
|
|
151
170
|
await destroySession(reply, s, BASE);
|
|
152
171
|
const ns = await createSession(reply, null, BASE, dev.username);
|
|
153
172
|
// the builder theme chosen earlier on this device
|
|
154
173
|
const theme = validTheme(req.cookies?.[THEME_COOKIE]);
|
|
155
174
|
if (theme) ns.state.__BTHEME = theme;
|
|
156
|
-
if (ws) ns.state.__WS =
|
|
175
|
+
if (o.ws) ns.state.__WS = o.ws;
|
|
157
176
|
// (sprint 121) the builder language: a link chosen on the sign-in page is stored for the developer; else theirs
|
|
158
177
|
if (picked) await chooseLanguage(reply, ns, picked);
|
|
159
178
|
else {
|
|
160
179
|
ns.state.__BLANG = validBuilderLanguage(dev.language) ?? 'en';
|
|
161
180
|
if (dev.language && ns.state.__BLANG !== 'en') reply.setCookie(LANG_COOKIE, ns.state.__BLANG, langCookieOptions());
|
|
162
181
|
}
|
|
163
|
-
if (
|
|
182
|
+
if (o.weak) ns.state.__WEAK = '1';
|
|
164
183
|
await saveState(ns);
|
|
165
|
-
if (
|
|
184
|
+
if (o.remember)
|
|
166
185
|
reply.setCookie(REMEMBER_COOKIE, Buffer.from(JSON.stringify({ w: workspace, u: dev.username })).toString('base64url'), {
|
|
167
186
|
path: BASE, httpOnly: true, sameSite: 'lax', maxAge: 365 * 24 * 3600, secure: process.env.COOKIE_SECURE === 'true',
|
|
168
187
|
});
|
|
169
188
|
else reply.clearCookie(REMEMBER_COOKIE, { path: BASE });
|
|
170
|
-
logActivity({ username: dev.username, event: 'login', ip, detail:
|
|
189
|
+
logActivity({ username: dev.username, event: 'login', ip, detail: o.detail });
|
|
171
190
|
return reply.redirect(next, 303);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
// (sprint 126, SEC-2) the second step: a code from the authenticator app, or a recovery code;
|
|
194
|
+
// (sprint 127) or a passkey, and "trust this browser" when the instance allows it
|
|
195
|
+
const MFA_SECONDS = 300;
|
|
196
|
+
const TRUST_COOKIE = 'pgkiln_btrust';
|
|
197
|
+
const trustThisBrowser = async (req: Req, reply: Parameters<typeof back>[0], username: string) => {
|
|
198
|
+
const days = await trustDays();
|
|
199
|
+
if (!days || req.body?.trust !== '1') return;
|
|
200
|
+
const token = await trustBrowser(username, days, String(req.headers['user-agent'] ?? ''));
|
|
201
|
+
reply.setCookie(TRUST_COOKIE, token, { path: BASE, httpOnly: true, sameSite: 'lax', maxAge: days * 86400, secure: process.env.COOKIE_SECURE === 'true' });
|
|
202
|
+
logActivity({ username, event: '2fa_trusted_browser', ip: clientIp(req), detail: `${days} days` });
|
|
203
|
+
};
|
|
204
|
+
const pendingMfa = (s: { state: Record<string, any> }) => {
|
|
205
|
+
try {
|
|
206
|
+
const p = JSON.parse(String(s.state.__MFA ?? ''));
|
|
207
|
+
return p && typeof p.u === 'string' && Date.now() - Number(p.at) < MFA_SECONDS * 1000 ? p as { u: string; ws: string | null; w: string; next: string; remember: boolean; weak: boolean } : null;
|
|
208
|
+
} catch {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
};
|
|
212
|
+
app.get(`${BASE}/login/2fa`, async (req: Req, reply) => {
|
|
213
|
+
const s = await getSession(req, reply, null, BASE);
|
|
214
|
+
if (s.username) return reply.redirect(`${BASE}/home`);
|
|
215
|
+
const p = pendingMfa(s);
|
|
216
|
+
const tx = translatorFor(validBuilderLanguage(s.state.__BLANG) ?? cookieLanguage(req));
|
|
217
|
+
if (!p) {
|
|
218
|
+
delete s.state.__MFA;
|
|
219
|
+
flash(s, tx('mfa.expired'), 'error');
|
|
220
|
+
return back(reply, s, `${BASE}/login`);
|
|
221
|
+
}
|
|
222
|
+
const error = takeFlash(s, '__ERROR');
|
|
223
|
+
await saveState(s);
|
|
224
|
+
const [keys, days] = await Promise.all([passkeysOf(p.u, relyingParty(req).rpId), trustDays()]);
|
|
225
|
+
return reply.type('text/html').send(documentShell(
|
|
226
|
+
`${tx('mfa.step_title')} · ${tx('shell.builder')}`,
|
|
227
|
+
html`<main class="signin">
|
|
228
|
+
<section class="signin-hero" aria-label="pgkiln">
|
|
229
|
+
<span class="signin-logo" aria-hidden="true">${brandMark()}</span>
|
|
230
|
+
<h1 class="signin-title">pgkiln</h1>
|
|
231
|
+
<p class="signin-tagline">${tx('signin.tagline')}</p>
|
|
232
|
+
</section>
|
|
233
|
+
<section class="signin-panel">
|
|
234
|
+
<form method="post" class="signin-form">
|
|
235
|
+
<h2>${tx('mfa.step_title')}</h2>
|
|
236
|
+
<p class="muted u-mt0">${tx('mfa.step_intro', { user: p.u })}</p>
|
|
237
|
+
${error ? html`<div class="alert alert-error" role="alert">${error}</div>` : ''}
|
|
238
|
+
${csrf(s)}
|
|
239
|
+
${keys.length ? html`<input type="hidden" name="credentialId"><input type="hidden" name="clientDataJSON"><input type="hidden" name="authenticatorData"><input type="hidden" name="signature">
|
|
240
|
+
<button class="btn btn-hot signin-submit" hidden data-passkey="${BASE}/login/2fa/passkey/options" data-passkey-submit="${BASE}/login/2fa/passkey" data-passkey-error="${tx('mfa.passkey_failed')}">${icon('fingerprint')} ${tx('mfa.use_passkey')}</button>
|
|
241
|
+
<p class="small" role="status" data-passkey-status></p>` : ''}
|
|
242
|
+
<div class="field signin-field"><label class="label" for="f_code">${tx('mfa.code')}</label>
|
|
243
|
+
<div class="signin-input">${icon('key')}<input id="f_code" name="code" required autocomplete="one-time-code" autofocus maxlength="20" spellcheck="false" autocapitalize="off"></div>
|
|
244
|
+
<small class="help">${tx('mfa.code_help')}</small></div>
|
|
245
|
+
${days ? html`<label class="check"><input type="checkbox" name="trust" value="1"> ${tx('mfa.trust_browser', { days })}</label>` : ''}
|
|
246
|
+
<button class="btn${keys.length ? '' : ' btn-hot'} signin-submit">${tx('mfa.verify')}</button>
|
|
247
|
+
<p class="muted small signin-hint"><a href="${BASE}/login">${tx('mfa.cancel')}</a> · ${tx('mfa.locked_out')}</p>
|
|
248
|
+
</form>
|
|
249
|
+
</section>
|
|
250
|
+
</main>`,
|
|
251
|
+
'login-body ide-login signin-body', {}, builderHead(),
|
|
252
|
+
{ theme: validTheme(req.cookies?.[THEME_COOKIE]) ?? 'dark', lang: tx.lang },
|
|
253
|
+
));
|
|
254
|
+
});
|
|
255
|
+
app.post(`${BASE}/login/2fa`, async (req: Req, reply) => {
|
|
256
|
+
const s = await getSession(req, reply, null, BASE);
|
|
257
|
+
const ip = clientIp(req);
|
|
258
|
+
const picked = validBuilderLanguage(s.state.__BLANG);
|
|
259
|
+
const tx = translatorFor(picked ?? cookieLanguage(req));
|
|
260
|
+
const p = pendingMfa(s);
|
|
261
|
+
if (req.body?.__csrf !== s.csrf_token || !p) {
|
|
262
|
+
delete s.state.__MFA;
|
|
263
|
+
flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
|
|
264
|
+
return back(reply, s, `${BASE}/login`);
|
|
265
|
+
}
|
|
266
|
+
if (await loginThrottled(null, p.u, ip)) {
|
|
267
|
+
logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
|
|
268
|
+
delete s.state.__MFA;
|
|
269
|
+
flash(s, tx('signin.throttled'), 'error');
|
|
270
|
+
return back(reply, s, `${BASE}/login`);
|
|
271
|
+
}
|
|
272
|
+
const ok = await checkSecondFactor(p.u, String(req.body?.code ?? '').slice(0, 40));
|
|
273
|
+
if (!ok) {
|
|
274
|
+
// counts with the failed passwords (lockout after the same number of attempts)
|
|
275
|
+
await logActivity({ username: p.u, event: 'login_failed', ip, detail: 'builder 2fa' });
|
|
276
|
+
flash(s, tx('mfa.wrong'), 'error');
|
|
277
|
+
return back(reply, s, `${BASE}/login/2fa`);
|
|
278
|
+
}
|
|
279
|
+
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
|
|
280
|
+
if (!dev) return back(reply, s, `${BASE}/login`);
|
|
281
|
+
if (ok.kind === 'recovery') {
|
|
282
|
+
const left = await codesLeft(p.u);
|
|
283
|
+
await logActivity({ username: p.u, event: '2fa_recovery_used', ip, detail: `${left} left` });
|
|
284
|
+
// to the Security page: one code fewer, maybe time for new ones
|
|
285
|
+
await trustThisBrowser(req, reply, p.u);
|
|
286
|
+
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: `${BASE}/security`, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (recovery code)' });
|
|
287
|
+
}
|
|
288
|
+
await trustThisBrowser(req, reply, p.u);
|
|
289
|
+
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa' });
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// (sprint 127) the second step with a passkey: options for navigator.credentials.get(), then the browser's answer
|
|
293
|
+
app.post(`${BASE}/login/2fa/passkey/options`, async (req: Req, reply) => {
|
|
294
|
+
const s = await getSession(req, reply, null, BASE);
|
|
295
|
+
const p = pendingMfa(s);
|
|
296
|
+
if (req.body?.__csrf !== s.csrf_token || !p) return reply.code(403).send('Sign in again.');
|
|
297
|
+
const rp = relyingParty(req);
|
|
298
|
+
const keys = await passkeysOf(p.u, rp.rpId);
|
|
299
|
+
if (!keys.length) return reply.code(404).send('No passkey for this site.');
|
|
300
|
+
const challenge = giveChallenge(s as Parameters<typeof giveChallenge>[0], 'get');
|
|
301
|
+
await saveState(s);
|
|
302
|
+
reply.header('cache-control', 'no-store');
|
|
303
|
+
return reply.send({ mode: 'get', challenge, rpId: rp.rpId, allow: keys.map((k) => ({ id: k.credential_id, transports: k.transports })) });
|
|
304
|
+
});
|
|
305
|
+
app.post(`${BASE}/login/2fa/passkey`, async (req: Req, reply) => {
|
|
306
|
+
const s = await getSession(req, reply, null, BASE);
|
|
307
|
+
const ip = clientIp(req);
|
|
308
|
+
const picked = validBuilderLanguage(s.state.__BLANG);
|
|
309
|
+
const tx = translatorFor(picked ?? cookieLanguage(req));
|
|
310
|
+
const p = pendingMfa(s);
|
|
311
|
+
const challenge = takeChallenge(s as Parameters<typeof takeChallenge>[0], 'get');
|
|
312
|
+
if (req.body?.__csrf !== s.csrf_token || !p) {
|
|
313
|
+
delete s.state.__MFA;
|
|
314
|
+
flash(s, tx(p ? 'signin.expired' : 'mfa.expired'), 'error');
|
|
315
|
+
return back(reply, s, `${BASE}/login`);
|
|
316
|
+
}
|
|
317
|
+
if (await loginThrottled(null, p.u, ip)) {
|
|
318
|
+
logActivity({ username: p.u, event: 'login_locked', ip, detail: 'builder 2fa' });
|
|
319
|
+
delete s.state.__MFA;
|
|
320
|
+
flash(s, tx('signin.throttled'), 'error');
|
|
321
|
+
return back(reply, s, `${BASE}/login`);
|
|
322
|
+
}
|
|
323
|
+
const rp = relyingParty(req);
|
|
324
|
+
const key = (await passkeysOf(p.u, rp.rpId)).find((k) => k.credential_id === String(req.body?.credentialId ?? ''));
|
|
325
|
+
let why = 'unknown passkey';
|
|
326
|
+
if (key && challenge) {
|
|
327
|
+
try {
|
|
328
|
+
const r = verifyAssertion(
|
|
329
|
+
{ clientDataJSON: String(req.body?.clientDataJSON ?? ''), authenticatorData: String(req.body?.authenticatorData ?? ''), signature: String(req.body?.signature ?? '') },
|
|
330
|
+
{ alg: key.alg, jwk: key.public_key, signCount: Number(key.sign_count) }, { challenge, ...rp });
|
|
331
|
+
if (await usePasskey(key.id, key.sign_count, r.signCount)) why = '';
|
|
332
|
+
else why = 'used at the same time elsewhere';
|
|
333
|
+
} catch (e) {
|
|
334
|
+
why = e instanceof WebAuthnError ? e.message : 'unreadable answer';
|
|
335
|
+
}
|
|
336
|
+
} else if (!challenge) why = 'no or an expired challenge';
|
|
337
|
+
if (why) {
|
|
338
|
+
await logActivity({ username: p.u, event: 'login_failed', ip, detail: `builder 2fa passkey: ${why}` });
|
|
339
|
+
flash(s, tx('mfa.passkey_failed'), 'error');
|
|
340
|
+
return back(reply, s, `${BASE}/login/2fa`);
|
|
341
|
+
}
|
|
342
|
+
const dev = await owner.one<{ username: string; language: string | null }>('select username, language from meta.developer where username = $1', [p.u]);
|
|
343
|
+
if (!dev) return back(reply, s, `${BASE}/login`);
|
|
344
|
+
await trustThisBrowser(req, reply, p.u);
|
|
345
|
+
return finishSignIn(req, reply, s, dev, { ws: p.ws, workspace: p.w, next: p.next, remember: p.remember, weak: p.weak, picked, detail: 'builder 2fa (passkey)' });
|
|
172
346
|
});
|
|
173
347
|
|
|
174
348
|
app.post(`${BASE}/logout`, async (req: Req, reply) => {
|
|
@@ -205,6 +379,7 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
205
379
|
await languageRoutes(app);
|
|
206
380
|
await workspaceRoutes(app);
|
|
207
381
|
await instanceRoutes(app);
|
|
382
|
+
await securityRoutes(app);
|
|
208
383
|
await wizardRoutes(app);
|
|
209
384
|
await appFromFileRoutes(app);
|
|
210
385
|
|
|
@@ -686,7 +861,8 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
686
861
|
app.get(`${BASE}/developers`, async (req: Req, reply) => {
|
|
687
862
|
const s = await developer(req, reply);
|
|
688
863
|
if (!s) return;
|
|
689
|
-
const devs = (await owner.query(
|
|
864
|
+
const devs = (await owner.query(`select username, is_admin, email, exists (select 1 from meta.developer_factor f where f.username = d.username) as mfa
|
|
865
|
+
from meta.developer d order by 1`)).rows;
|
|
690
866
|
const admin = devs.some((d) => d.username === s.username && d.is_admin);
|
|
691
867
|
const me = devs.find((d) => d.username === s.username);
|
|
692
868
|
const tx = tr(s);
|
|
@@ -703,11 +879,14 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
703
879
|
<div class="buttons"><button class="btn">${tx('dev.save')}</button></div></form>`)}
|
|
704
880
|
${region(tx('dev.accounts'), html`
|
|
705
881
|
<p class="muted u-mt0">${tx('dev.accounts_help')}</p>
|
|
706
|
-
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th></th></tr></thead><tbody>
|
|
882
|
+
<div class="table-wrap"><table class="report report-reflow"><thead><tr><th>${tx('dev.username')}</th><th>${tx('dev.role')}</th><th>${tx('dev.email_col')}</th><th>${tx('mfa.column')}</th><th></th></tr></thead><tbody>
|
|
707
883
|
${devs.map((d) => html`<tr><td data-label="${tx('dev.username')}">${d.username}${d.username === s.username ? ` ${tx('dev.you')}` : ''}</td><td data-label="${tx('dev.role')}">${d.is_admin ? tx('dev.administrator') : tx('dev.developer')}</td>
|
|
708
884
|
<td data-label="${tx('dev.email_col')}">${admin && d.username !== s.username
|
|
709
885
|
? html`<form method="post" action="${BASE}/developers/email" class="search u-m0 u-mwnone">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input name="email" type="email" value="${d.email ?? ''}" aria-label="${tx('dev.email_of', { user: d.username })}" placeholder="${tx('dev.none')}"><button class="btn">${tx('dev.save')}</button></form>`
|
|
710
|
-
: html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td
|
|
886
|
+
: html`<span class="wrap-anywhere">${d.email ?? ''}</span>`}</td>
|
|
887
|
+
<td data-label="${tx('mfa.column')}">${d.mfa ? html`${icon('lock')} ${tx('mfa.on')}${admin && d.username !== s.username ? html` <details class="menu u-inline"><summary class="link-button">${tx('mfa.reset')}</summary><div class="menu-panel"><form method="post" action="${BASE}/developers/reset-2fa" class="menu-section">${csrf(s)}<input type="hidden" name="username" value="${d.username}">
|
|
888
|
+
${input(`reason_${d.username}`.replace(/[^\w]/g, '_'), tx('mfa.reason'), '', { required: true, help: tx('mfa.reset_help', { user: d.username }) })}
|
|
889
|
+
<button class="btn btn-sm btn-danger">${tx('mfa.reset_btn')}</button></form></div></details>` : ''}` : html`<span class="muted">${tx('mfa.off')}</span>`}</td><td>${d.username === s.username || !admin ? '' : html`
|
|
711
890
|
<form method="post" action="${BASE}/developers/admin" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><input type="hidden" name="is_admin" value="${d.is_admin ? 'false' : 'true'}"><button class="link-button">${d.is_admin ? tx('dev.make_dev') : tx('dev.make_admin')}</button></form>
|
|
712
891
|
· <form method="post" action="${BASE}/developers/delete" class="u-inline">${csrf(s)}<input type="hidden" name="username" value="${d.username}"><button class="link-button" data-confirm="${tx('dev.remove_confirm', { user: d.username })}">${tx('dev.remove')}</button></form>`}</td></tr>`)}
|
|
713
892
|
</tbody></table></div>
|
|
@@ -761,8 +940,9 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
761
940
|
[s.username, req.body?.current ?? '', req.body?.password],
|
|
762
941
|
);
|
|
763
942
|
if (r.rowCount !== 1) throw new Error(tr(s)('dev.err.current'));
|
|
764
|
-
// End all other builder sessions of this developer.
|
|
943
|
+
// End all other builder sessions of this developer, and (sprint 127) forget the browsers they trusted.
|
|
765
944
|
await owner.query('delete from meta.session where app_id is null and username = $1 and id <> $2', [s.username, s.id]);
|
|
945
|
+
await forgetBrowsers(s.username!);
|
|
766
946
|
delete s.state.__WEAK;
|
|
767
947
|
flash(s, tr(s)('dev.pw_changed'));
|
|
768
948
|
} catch (e) {
|
|
@@ -791,6 +971,10 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
791
971
|
if (!s) return;
|
|
792
972
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators change roles.');
|
|
793
973
|
if (req.body?.username && req.body.username !== s.username) {
|
|
974
|
+
if (req.body.is_admin !== 'true' && (await leavesTooFewAdmins(req.body.username))) {
|
|
975
|
+
flash(s, tr(s)('mfa.keep_admins'), 'error');
|
|
976
|
+
return back(reply, s, `${BASE}/developers`);
|
|
977
|
+
}
|
|
794
978
|
await owner.query('update meta.developer set is_admin = $2 where username = $1', [req.body.username, req.body.is_admin === 'true']);
|
|
795
979
|
flash(s, tr(s)('dev.role_changed'));
|
|
796
980
|
}
|
|
@@ -802,6 +986,10 @@ export async function builderRoutes(app: FastifyInstance) {
|
|
|
802
986
|
if (!s) return;
|
|
803
987
|
if (!(await isAdmin(s.username))) return reply.code(403).send('Only administrators remove developers.');
|
|
804
988
|
if (req.body?.username && req.body.username !== s.username) {
|
|
989
|
+
if (await leavesTooFewAdmins(req.body.username)) {
|
|
990
|
+
flash(s, tr(s)('mfa.keep_admins'), 'error');
|
|
991
|
+
return back(reply, s, `${BASE}/developers`);
|
|
992
|
+
}
|
|
805
993
|
await owner.query('delete from meta.developer where username = $1', [req.body.username]);
|
|
806
994
|
await owner.query('delete from meta.session where app_id is null and username = $1', [req.body.username]);
|
|
807
995
|
flash(s, tr(s)('dev.removed'));
|